Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }