Skip to content

Commit

Schedules run at most every 5 minutes, pause after 60 days without a push, and wait an hour after billing refuses a job (actions 0011)

syntaqxcommitted Parent4a1b684Browse files
6 files+131−30/6 viewed
+23−0
7878 Why each of these is missing, and what to use instead, is on
7979 [What g1t can't do yet](/about/limitations/#actions-and-runners).
8080
81+## Schedules
82+
83+A workflow with `on: schedule` runs on the default branch's latest commit,
84+at each time its cron lines name, in UTC:
85+
86+```yaml
87+on:
88+ schedule:
89+ - cron: "0 9 * * mon" # Mondays at 09:00 UTC
90+ - cron: "*/30 * * * *" # every 30 minutes
91+```
92+
93+Each line has five fields: minute, hour, day of month, month and day of
94+the week. A field takes `*`, a number, a range `1-5`, a list `1,15` and a
95+step `*/10`; days take `mon` to `sun`, and months `jan` to `dec`.
96+
97+| Rule | What happens |
98+| --- | --- |
99+| Every 5 minutes at most | A schedule more frequent than every 5 minutes, such as `* * * * *` or `*/2 * * * *`, runs every 5 minutes instead, on the five-minute marks (:00, :05, :10 and so on). |
100+| No push for 60 days | Schedules pause in a repository that has had no push for 60 days. The next push to any branch resumes them. Other events and `workflow_dispatch` still start the workflow. |
101+| Actions not paid for | When a scheduled run's job could not start because the workspace's plan, spend limit or the open-source pool does not cover it, that run fails and says why, and the workflow's schedule waits an hour before it tries again. |
102+| Archived repository | Schedules wait until the repository is unarchived. |
103+
81104 ## Actions and workflows from other repositories
82105
83106 A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's
+52−0
100100 };
101101 self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok
102102 }
103+
104+ /// Whether its minutes come closer together than
105+ /// [`MIN_INTERVAL_MINUTES`], counting round the hour.
106+ pub fn too_frequent(&self) -> bool {
107+ let set: Vec<usize> = (0..60).filter(|&m| self.minutes[m]).collect();
108+ let Some(&first) = set.first() else { return false };
109+ let wrap = first + 60 - set[set.len() - 1];
110+ set.windows(2).map(|pair| pair[1] - pair[0]).chain([wrap]).any(|gap| gap < MIN_INTERVAL_MINUTES as usize)
111+ }
112+
113+ /// Whether a workflow on this schedule runs in the minute starting at
114+ /// `ms`. A schedule no more frequent than every
115+ /// [`MIN_INTERVAL_MINUTES`] runs when it fires. A more frequent one
116+ /// runs on the five-minute marks, at each one it fired at or since the
117+ /// last: at most every five minutes.
118+ pub fn runs_at(&self, ms: u64) -> bool {
119+ if !self.too_frequent() {
120+ return self.fires_at(ms);
121+ }
122+ let minute = ms / 60_000;
123+ minute % MIN_INTERVAL_MINUTES == 0
124+ && (0..MIN_INTERVAL_MINUTES).any(|back| minute >= back && self.fires_at((minute - back) * 60_000))
125+ }
103126 }
104127
128+/// The shortest interval a workflow's schedule runs at, in minutes.
129+pub const MIN_INTERVAL_MINUTES: u64 = 5;
130+
105131 /// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
106132 fn civil_from_days(days: i64) -> (i64, u32, u32) {
107133 let z = days + 719_468;
168194 }
169195
170196 #[test]
197+ fn schedules_run_at_most_every_five_minutes() {
198+ let runs = |text: &str| -> Vec<u64> {
199+ let schedule = Schedule::parse(text).unwrap();
200+ (0..30).filter(|&m| schedule.runs_at(at(MONDAY, 3, m))).collect()
201+ };
202+ assert_eq!(runs("* * * * *"), [0, 5, 10, 15, 20, 25]);
203+ assert_eq!(runs("*/2 * * * *"), [0, 5, 10, 15, 20, 25]);
204+ // Every five minutes or less often: exactly when it fires.
205+ assert_eq!(runs("*/5 * * * *"), [0, 5, 10, 15, 20, 25]);
206+ assert_eq!(runs("7,17 * * * *"), [7, 17]);
207+ assert_eq!(runs("*/10 * * * *"), [0, 10, 20]);
208+ // Two minutes close together: the later one waits for the mark.
209+ assert_eq!(runs("0,3 * * * *"), [0, 5]);
210+ // Close across the hour counts too.
211+ assert!(Schedule::parse("2,58 * * * *").unwrap().too_frequent());
212+ assert!(!Schedule::parse("0 9 * * mon").unwrap().too_frequent());
213+ // Every minute of one hour: the marks of that hour, and the one
214+ // that closes it.
215+ let nine = Schedule::parse("* 9 * * *").unwrap();
216+ assert!(nine.runs_at(at(MONDAY, 9, 0)));
217+ assert!(!nine.runs_at(at(MONDAY, 9, 1)));
218+ assert!(nine.runs_at(at(MONDAY, 10, 0)));
219+ assert!(!nine.runs_at(at(MONDAY, 10, 5)));
220+ }
221+
222+ #[test]
171223 fn nonsense_is_refused() {
172224 for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
173225 assert!(Schedule::parse(text).is_err(), "{text}");
+12−0
1+-- Schedules pause in a repository with no push for 60 days, and wait an
2+-- hour after billing refused to start one of the workflow's scheduled jobs.
3+
4+-- When the repository last had a push, at a day's resolution; written on
5+-- pushes to repositories with a scheduled workflow. Workflows already here
6+-- start counting now.
7+ALTER TABLE workflows ADD COLUMN pushed_at TEXT;
8+UPDATE workflows SET pushed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE crons != '[]';
9+
10+-- Until when its schedule waits: set when billing refused a job of one of
11+-- its scheduled runs.
12+ALTER TABLE workflows ADD COLUMN schedule_refused_until TEXT;
+6−0
5959 pub const SELF_HOSTED_MAX_TIMEOUT_MINUTES: u32 = 24 * 60;
6060 /// A running job that has said nothing for this long is taken as lost.
6161 pub const SILENT_MS: u64 = 10 * 60 * 1000;
62+/// Schedules pause in a repository that has had no push for this long; the
63+/// next push resumes them.
64+pub const SCHEDULE_IDLE_MS: u64 = 60 * 24 * 60 * 60 * 1000;
65+/// How long a workflow's schedule waits after billing refused to start a
66+/// job of one of its scheduled runs, before it tries again.
67+pub const SCHEDULE_REFUSED_MS: u64 = 60 * 60 * 1000;
6268 pub const SITE: &str = "https://g1t.sh";
6369 pub const API: &str = "https://api.g1t.sh";
6470
+11−0
13481348 .await
13491349 .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}")));
13501350 if let Outcome::Fail(refused) = started {
1351+ // Billing would not pay for a scheduled run's job: the
1352+ // schedule waits rather than making runs only to refuse them.
1353+ if refused.code == FailureCode::PaymentRequired
1354+ && let Some(run) = run.as_ref().filter(|run| run.event == "schedule")
1355+ {
1356+ self.db
1357+ .prepare("UPDATE workflows SET schedule_refused_until = ? WHERE id = ?")
1358+ .bind(&[rfc3339(now_ms() + crate::SCHEDULE_REFUSED_MS).into(), run.workflow_id.as_str().into()])?
1359+ .run()
1360+ .await?;
1361+ }
13511362 Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?;
13521363 } else {
13531364 self.job_started(&job.id).await?;
+27−3
1111 use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, Repo, RepoPath};
1212 use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
1313 use g1t_contracts::{FailureCode, Outcome, User, new_id};
14+use g1t_contracts::time::rfc3339;
1415 use g1t_kit::now_ms;
1516 use serde_json::{Map, Value, json};
1617 use worker::Result;
511512 })
512513 }
513514
515+ /// A push keeps the repository's schedules going (`run_schedules`).
516+ /// Written at most once a day, and only on scheduled workflows.
517+ async fn note_push(&self, repo_id: &str) -> Result<()> {
518+ let at = now_ms();
519+ self.db
520+ .prepare("UPDATE workflows SET pushed_at = ? WHERE repo_id = ? AND crons != '[]' AND (pushed_at IS NULL OR pushed_at < ?)")
521+ .bind(&[rfc3339(at).into(), repo_id.into(), rfc3339(at.saturating_sub(24 * 60 * 60 * 1000)).into()])?
522+ .run()
523+ .await?;
524+ Ok(())
525+ }
526+
514527 pub async fn on_event(&self, event: &Event) -> Result<()> {
515528 let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
516529 let mut mapped = github_events(&event.kind);
519532 mapped.push(("create", None));
520533 }
521534 let pushed_default = event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true);
535+ if event.kind == "git.push" {
536+ self.note_push(repo_id).await?;
537+ }
522538 if mapped.is_empty() && !pushed_default {
523539 return Ok(());
524540 }
717733 self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await
718734 }
719735
720− /// Scheduled workflows whose cron fires this minute, on the default branch.
736+ /// Scheduled workflows that run this minute, on the default branch: at
737+ /// most every five minutes (`cron::Schedule::runs_at`). Not in a
738+ /// repository with no push for [`crate::SCHEDULE_IDLE_MS`], nor for an
739+ /// hour after billing refused one of the workflow's scheduled jobs
740+ /// ([`crate::SCHEDULE_REFUSED_MS`]): no run is made only to be refused.
721741 pub async fn run_schedules(&self, minute: u64) -> Result<()> {
722742 let rows = self
723743 .db
724− .prepare("SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL")
744+ .prepare(
745+ "SELECT * FROM workflows WHERE state = 'active' AND crons != '[]' AND error IS NULL
746+ AND COALESCE(pushed_at, updated_at) >= ? AND (schedule_refused_until IS NULL OR schedule_refused_until <= ?)",
747+ )
748+ .bind(&[rfc3339(minute.saturating_sub(crate::SCHEDULE_IDLE_MS)).into(), rfc3339(minute).into()])?
725749 .all()
726750 .await?
727751 .results::<WorkflowRow>()?;
728752 for row in rows {
729753 let crons: Vec<String> = serde_json::from_str(&row.crons).unwrap_or_default();
730− let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.fires_at(minute))) else {
754+ let Some(cron) = crons.iter().find(|cron| g1t_actions::cron::Schedule::parse(cron).is_ok_and(|s| s.runs_at(minute))) else {
731755 continue;
732756 };
733757 let Ok(workflow) = workflow::parse(&row.source) else { continue };