vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Let a job budget its whole run
e7aa0785cb69ace8c1338cc0b924113ea52a7abb · Keenan Nemetz · 6d ago · parent 2bc0ae74
Let a job budget its whole run A step was capped at an hour and a job was not capped at all, so a job of several slow steps held a runner for as long as they took, with no number saying what the job was allowed to spend. And one long step — a session, a soak test, a slow suite — could not be expressed, because the only clock was per step and its ceiling was an hour. A job may now declare `timeout: 2h`: a wall clock its steps share, starting with the first step. A step that declares its own timeout is cut short when the budget ends first, and a step that declares none runs with whatever is left, so one long step needs only the job's number. A budget spent before a step starts stops the job there instead of running the step on its own cap. The budget is optional, pinned with the run, and spent by a local run the same way, so nothing changes for a workflow that does not declare one. The step ceiling rises from one hour to four to make a single long step possible; the default stays ten minutes. The image build is not charged to the budget — it has its own cap, so a slow pull cannot eat the time the steps were given. A step timeout longer than its job's budget is refused at push time: the budget would end first, so the step could never use what it asked for. Closes #161. # Conflicts: # ci/agent/agent.go # cmd/vole/workflowrun.go # docs/reference/workflow-reference.md
ci/workflow.go +72−6
| @@ -73,10 +73,13 @@ const ( | |||
| 73 | 73 | // MaxEventPayloadBytes bounds the event JSON pinned with a run. It is read as | |
| 74 | 74 | // data from a mounted file, never interpolated into a step. | |
| 75 | 75 | MaxEventPayloadBytes = 64 << 10 | |
| 76 | // DefaultTimeout applies to a step that does not set one. A hung step must not | ||
| 77 | // hold a runner forever. | ||
| 76 | // DefaultTimeout applies to a step that does not set one and whose job has no | ||
| 77 | // budget of its own. A hung step must not hold a runner forever. | ||
| 78 | 78 | DefaultTimeout = 10 * time.Minute | |
| 79 | MaxTimeout = time.Hour | ||
| 79 | // MaxTimeout bounds what a step may ask for and what a job may budget. A step | ||
| 80 | // that declares nothing inside a job with a budget inherits it, so this is also | ||
| 81 | // the longest a single step can effectively run. | ||
| 82 | MaxTimeout = 4 * time.Hour | ||
| 80 | 83 | // Version is the only schema this binary understands. | |
| 81 | 84 | Version = 1 | |
| 82 | 85 | ) | |
| @@ -149,11 +152,48 @@ func ValidNetwork(s string) error { | |||
| 149 | 152 | } | |
| 150 | 153 | ||
| 151 | 154 | type Step struct { | |
| 152 | Name string | ||
| 153 | Run string | ||
| 155 | Name string | ||
| 156 | Run string | ||
| 157 | // Timeout is the step's own `timeout:`, or zero when it declares none. Zero is | ||
| 158 | // not "no limit": StepLimit decides what an undeclared step gets, which is the | ||
| 159 | // job's remaining budget or DefaultTimeout. | ||
| 154 | 160 | Timeout time.Duration | |
| 155 | 161 | } | |
| 156 | 162 | ||
| 163 | // StepLimit is how long one step may run, given what is left of its job's budget | ||
| 164 | // and the step's own timeout. left is negative when the job declares no budget. | ||
| 165 | // | ||
| 166 | // A step that declares a timeout gets it, unless the job's budget runs out first. A | ||
| 167 | // step that declares nothing gets whatever is left of the budget, so a job that | ||
| 168 | // says `timeout: 2h` is enough for a single long step, and falls back to | ||
| 169 | // DefaultTimeout when the job has no budget. A result of zero means the budget is | ||
| 170 | // spent: the step must not start, and the job has failed. | ||
| 171 | func BudgetLeft(deadline, now time.Time) time.Duration { | ||
| 172 | if deadline.IsZero() { | ||
| 173 | return -1 | ||
| 174 | } | ||
| 175 | if left := deadline.Sub(now); left > 0 { | ||
| 176 | return left | ||
| 177 | } | ||
| 178 | return 0 | ||
| 179 | } | ||
| 180 | |||
| 181 | func StepLimit(left, own time.Duration) time.Duration { | ||
| 182 | if left < 0 { | ||
| 183 | if own > 0 { | ||
| 184 | return own | ||
| 185 | } | ||
| 186 | return DefaultTimeout | ||
| 187 | } | ||
| 188 | if left <= 0 { | ||
| 189 | return 0 | ||
| 190 | } | ||
| 191 | if own > 0 && own < left { | ||
| 192 | return own | ||
| 193 | } | ||
| 194 | return left | ||
| 195 | } | ||
| 196 | |||
| 157 | 197 | // Artifact declares an output of a job: a path pattern whose matches are uploaded | |
| 158 | 198 | // when the steps succeed. It is a declaration rather than a step that calls the | |
| 159 | 199 | // CLI, because a workflow is validated when it is pushed — a typo here fails the | |
| @@ -188,6 +228,12 @@ type Job struct { | |||
| 188 | 228 | // that could provide bridge. The job decides, so the same workflow behaves the same | |
| 189 | 229 | // way on every runner. | |
| 190 | 230 | Network string | |
| 231 | // Timeout is the wall clock the job's steps share, from `timeout:`, or zero when | ||
| 232 | // the job does not budget one. It is spent by the steps in order: a step that | ||
| 233 | // declares its own timeout is still cut short when the budget ends first, and a | ||
| 234 | // step that declares none runs with whatever is left. Zero keeps the behaviour a | ||
| 235 | // workflow had before the field: each step is bounded only by its own timeout. | ||
| 236 | Timeout time.Duration | ||
| 191 | 237 | // Image is the image the job runs in. It is either a registry reference from | |
| 192 | 238 | // `image:` or `file:<name>` for a definition under .vole/images. | |
| 193 | 239 | Image string | |
| @@ -760,6 +806,7 @@ type rawJob struct { | |||
| 760 | 806 | Steps []rawStep `yaml:"steps"` | |
| 761 | 807 | RunsOn rawLabels `yaml:"runs-on"` | |
| 762 | 808 | Network string `yaml:"network"` | |
| 809 | Timeout string `yaml:"timeout"` | ||
| 763 | 810 | Image string `yaml:"image"` | |
| 764 | 811 | ImageFile string `yaml:"image-file"` | |
| 765 | 812 | Artifacts []rawArtifact `yaml:"artifacts"` | |
| @@ -1120,6 +1167,21 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 1120 | 1167 | job.Network = network | |
| 1121 | 1168 | } | |
| 1122 | 1169 | } | |
| 1170 | // A job budget is the clock its steps share. It is optional: without one each | ||
| 1171 | // step is still bounded, which is how every workflow worked before the field. | ||
| 1172 | if raw := strings.TrimSpace(jr.Timeout); raw != "" { | ||
| 1173 | d, err := time.ParseDuration(raw) | ||
| 1174 | switch { | ||
| 1175 | case err != nil: | ||
| 1176 | bad("jobs.%s.timeout: %q is not a duration (try 90s, 10m, 2h)", id, raw) | ||
| 1177 | case d <= 0: | ||
| 1178 | bad("jobs.%s.timeout: must be positive", id) | ||
| 1179 | case d > MaxTimeout: | ||
| 1180 | bad("jobs.%s.timeout: %s exceeds the %s limit", id, d, MaxTimeout) | ||
| 1181 | default: | ||
| 1182 | job.Timeout = d | ||
| 1183 | } | ||
| 1184 | } | ||
| 1123 | 1185 | for i, sr := range jr.Steps { | |
| 1124 | 1186 | where := fmt.Sprintf("jobs.%s.steps[%d]", id, i) | |
| 1125 | 1187 | run := strings.TrimSpace(sr.Run) | |
| @@ -1130,7 +1192,7 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 1130 | 1192 | if len(sr.Run) > MaxStepBytes { | |
| 1131 | 1193 | bad("%s: run is %d bytes, limit %d", where, len(sr.Run), MaxStepBytes) | |
| 1132 | 1194 | } | |
| 1133 | step := Step{Name: strings.TrimSpace(sr.Name), Run: run, Timeout: DefaultTimeout} | ||
| 1195 | step := Step{Name: strings.TrimSpace(sr.Name), Run: run} | ||
| 1134 | 1196 | if sr.Timeout != "" { | |
| 1135 | 1197 | d, err := time.ParseDuration(sr.Timeout) | |
| 1136 | 1198 | switch { | |
| @@ -1140,6 +1202,10 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 1140 | 1202 | bad("%s.timeout: must be positive", where) | |
| 1141 | 1203 | case d > MaxTimeout: | |
| 1142 | 1204 | bad("%s.timeout: %s exceeds the %s limit", where, d, MaxTimeout) | |
| 1205 | case job.Timeout > 0 && d > job.Timeout: | ||
| 1206 | // The job's budget would end first, so the step could never use the | ||
| 1207 | // timeout it asked for. That is a mistake, not a preference. | ||
| 1208 | bad("%s.timeout: %s is longer than the job's timeout %s", where, d, job.Timeout) | ||
| 1143 | 1209 | default: | |
| 1144 | 1210 | step.Timeout = d | |
| 1145 | 1211 | } | |