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)
6 files+131−30/6 viewed
| 78 | 78 | Why each of these is missing, and what to use instead, is on | |
| 79 | 79 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| 80 | 80 | ||
| 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 | + | ||
| 81 | 104 | ## Actions and workflows from other repositories | |
| 82 | 105 | ||
| 83 | 106 | A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's |
| 100 | 100 | }; | |
| 101 | 101 | self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok | |
| 102 | 102 | } | |
| 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 | + | } | |
| 103 | 126 | } | |
| 104 | 127 | ||
| 128 | + | /// The shortest interval a workflow's schedule runs at, in minutes. | |
| 129 | + | pub const MIN_INTERVAL_MINUTES: u64 = 5; | |
| 130 | + | ||
| 105 | 131 | /// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm). | |
| 106 | 132 | fn civil_from_days(days: i64) -> (i64, u32, u32) { | |
| 107 | 133 | let z = days + 719_468; | |
| ⋯ | |||
| 168 | 194 | } | |
| 169 | 195 | ||
| 170 | 196 | #[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] | |
| 171 | 223 | fn nonsense_is_refused() { | |
| 172 | 224 | for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] { | |
| 173 | 225 | assert!(Schedule::parse(text).is_err(), "{text}"); | |
| 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; |
| 59 | 59 | pub const SELF_HOSTED_MAX_TIMEOUT_MINUTES: u32 = 24 * 60; | |
| 60 | 60 | /// A running job that has said nothing for this long is taken as lost. | |
| 61 | 61 | 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; | |
| 62 | 68 | pub const SITE: &str = "https://g1t.sh"; | |
| 63 | 69 | pub const API: &str = "https://api.g1t.sh"; | |
| 64 | 70 |
| 1348 | 1348 | .await | |
| 1349 | 1349 | .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}"))); | |
| 1350 | 1350 | 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 | + | } | |
| 1351 | 1362 | Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?; | |
| 1352 | 1363 | } else { | |
| 1353 | 1364 | self.job_started(&job.id).await?; |
| 11 | 11 | use g1t_contracts::repos::{Commit, CompareArgs, Comparison, LogArgs, Repo, RepoPath}; | |
| 12 | 12 | use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs}; | |
| 13 | 13 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 14 | + | use g1t_contracts::time::rfc3339; | |
| 14 | 15 | use g1t_kit::now_ms; | |
| 15 | 16 | use serde_json::{Map, Value, json}; | |
| 16 | 17 | use worker::Result; | |
| ⋯ | |||
| 511 | 512 | }) | |
| 512 | 513 | } | |
| 513 | 514 | ||
| 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 | + | ||
| 514 | 527 | pub async fn on_event(&self, event: &Event) -> Result<()> { | |
| 515 | 528 | let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) }; | |
| 516 | 529 | let mut mapped = github_events(&event.kind); | |
| ⋯ | |||
| 519 | 532 | mapped.push(("create", None)); | |
| 520 | 533 | } | |
| 521 | 534 | 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 | + | } | |
| 522 | 538 | if mapped.is_empty() && !pushed_default { | |
| 523 | 539 | return Ok(()); | |
| 524 | 540 | } | |
| ⋯ | |||
| 717 | 733 | self.record_failed_run(&row, subject.git_ref.as_str(), &subject.sha, event_key, actor_id, sender, problem).await | |
| 718 | 734 | } | |
| 719 | 735 | ||
| 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. | |
| 721 | 741 | pub async fn run_schedules(&self, minute: u64) -> Result<()> { | |
| 722 | 742 | let rows = self | |
| 723 | 743 | .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()])? | |
| 725 | 749 | .all() | |
| 726 | 750 | .await? | |
| 727 | 751 | .results::<WorkflowRow>()?; | |
| 728 | 752 | for row in rows { | |
| 729 | 753 | 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 { | |
| 731 | 755 | continue; | |
| 732 | 756 | }; | |
| 733 | 757 | let Ok(workflow) = workflow::parse(&row.source) else { continue }; | |