Repository navigation
Expand file tree
/
Copy pathexecutor.go
More file actions
341 lines (312 loc) · 10.9 KB
/
Copy pathexecutor.go
File metadata and controls
341 lines (312 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
package workflow
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/goware/workflow/store"
)
// executePlan returns StatusSucceeded or StatusStuck only once the write that
// ends the run was accepted under this executor's claim. Every other exit
// returns StatusRunning: the run continues in another process or on a later
// tick.
func (m *Manager) executePlan(ctx context.Context, claimed *store.ClaimedRun) Status {
claimID := claimed.ClaimID
runID := claimed.Run.ID
workflowName := claimed.Run.Name
tasks := claimed.Tasks
current := claimed.Run.CurrentTask
// marked remembers the task this executor already set to running. An
// in-process retry re-enters with the task still running, and
// MarkTaskRunning would return false because it is no longer pending.
marked := int16(-1)
runLog := m.log.With(slog.String("run_id", runID.String()), slog.String("run", workflowName))
input, handlerCtx, err := m.openRun(ctx, claimed.Run)
if err != nil {
// No task has run, so this costs no attempt budget.
msg := "workflow: run data could not be opened"
parked, perr := m.db.ParkRunWithoutAttempt(ctx, runID, current, store.TaskFailure{Message: msg}, claimID)
if perr != nil {
return m.abandon(ctx, runLog, "workflow: park on unopenable run data failed", perr, runID, current, claimID)
}
if !parked {
return StatusRunning
}
runLog.Log(ctx, LevelAlert, "workflow: parked on unopenable run data", slog.Any("error", err))
m.signal(runID, StatusStuck)
return StatusStuck
}
for {
if int(current) >= len(tasks) {
// Checked before the drain check so a shutdown that arrives after
// the last task finished still writes the run's success.
ok, err := m.db.CompleteRun(ctx, runID, claimID)
if err != nil {
return m.abandon(ctx, runLog, "workflow: complete run failed", err, runID, current, claimID)
}
if !ok {
return StatusRunning
}
m.signal(runID, StatusSucceeded)
return StatusSucceeded
}
if ctx.Err() != nil || m.Draining() {
m.release(ctx, runID, current, claimID)
return StatusRunning
}
task := tasks[current]
seq := task.Seq
taskLog := runLog.With(slog.Int("seq", int(seq)), slog.String("handler", task.Handler))
if task.Status == store.TaskStatusSkipped {
ok, err := m.db.AdvanceSkippedTask(ctx, runID, seq, claimID)
if err != nil {
return m.abandon(ctx, taskLog, "workflow: advance skipped task failed", err, runID, seq, claimID)
}
if !ok {
return StatusRunning
}
current++
if !m.heartbeat(ctx, runID, current, claimID) {
return StatusRunning
}
continue
}
handler, sc, resolved := m.reg.resolve(workflowName, task.Handler)
if !resolved {
msg := fmt.Sprintf("workflow: version skew — no handler %q registered for run %q", task.Handler, workflowName)
ok, err := m.db.ParkRunWithoutAttempt(ctx, runID, seq, store.TaskFailure{Message: msg}, claimID)
if err != nil {
return m.abandon(ctx, taskLog, "workflow: park on version skew failed", err, runID, seq, claimID)
}
if !ok {
return StatusRunning
}
taskLog.Log(ctx, LevelAlert, "workflow: parked on version skew", slog.Any("error", errors.New(msg)))
m.signal(runID, StatusStuck)
return StatusStuck
}
// The post-failure check only runs when a handler returns. A process
// that died mid-attempt never gets there, and SweepStuck bumps
// attempt_count on reclaim, so without this the run retries forever.
budget := cmp.Or(sc.maxAttempts, m.cfg.DefaultMaxAttempts)
if task.AttemptCount >= budget {
msg := fmt.Sprintf("workflow: attempt budget exhausted after %d attempts", task.AttemptCount)
ok, err := m.db.ParkRunWithoutAttempt(ctx, runID, seq, store.TaskFailure{Message: msg}, claimID)
if err != nil {
return m.abandon(ctx, taskLog, "workflow: park on exhausted budget failed", err, runID, seq, claimID)
}
if !ok {
return StatusRunning
}
taskLog.Log(ctx, LevelAlert, "workflow: parked on exhausted budget",
slog.Int("attempt_count", task.AttemptCount), slog.Int("budget", budget),
slog.Any("error", errors.New(msg)))
m.signal(runID, StatusStuck)
return StatusStuck
}
if marked != seq {
ok, err := m.db.MarkTaskRunning(ctx, runID, seq, claimID)
if err != nil {
return m.abandon(ctx, taskLog, "workflow: mark task running failed", err, runID, seq, claimID)
}
if !ok {
// Another executor owns this task. That is legitimate, but
// without a log line it looks the same as a broken claim check.
taskLog.DebugContext(ctx, "workflow: task already claimed elsewhere")
return StatusRunning
}
marked = seq
}
core := taskRunCore{
log: taskLog.With(slog.Int("attempt", task.AttemptCount+1)),
runID: runID,
seq: seq,
input: input,
outputs: priorOutputs(tasks),
attempt: task.AttemptCount + 1,
budget: budget,
}
stageCtx, cancel := context.WithTimeout(handlerCtx, cmp.Or(sc.timeout, m.cfg.DefaultTaskTimeout))
raw, err := handler(stageCtx, core)
cancel()
var output json.RawMessage
if err == nil {
output, err = json.Marshal(raw)
if err != nil {
err = Permanent(fmt.Errorf("marshal task output: %w", err))
}
}
// A drain does not count as an attempt: persist finished work, then
// release for immediate re-claim. The data layer detaches both writes
// from ctx, which may be the shutdown signal itself.
if ctx.Err() != nil || m.Draining() {
if err == nil {
_, perr := m.db.PersistTaskSuccess(ctx, runID, seq, output, claimID)
if perr != nil {
return m.hold(ctx, taskLog, "workflow: persist task success on drain failed", perr)
}
}
m.release(ctx, runID, seq, claimID)
return StatusRunning
}
if err == nil {
ok, perr := m.db.PersistTaskSuccess(ctx, runID, seq, output, claimID)
if perr != nil {
return m.hold(ctx, taskLog, "workflow: persist task success failed", perr)
}
if !ok {
return StatusRunning
}
task.Status = store.TaskStatusSucceeded
task.Output = output
current++
if !m.heartbeat(ctx, runID, current, claimID) {
return StatusRunning
}
continue
}
failure := store.TaskFailure{Message: err.Error(), Code: codeOf(err)}
attempt := task.AttemptCount + 1
backoff := nextBackoff(sc, m.cfg, attempt)
_, permanent := errors.AsType[*PermanentError](err)
if permanent || attempt >= budget {
parked, perr := m.db.ParkRunAfterAttempt(ctx, runID, seq, failure, claimID)
if perr != nil {
return m.hold(ctx, taskLog, "workflow: park run failed", perr)
}
if !parked {
return StatusRunning
}
taskLog.Log(ctx, LevelAlert, "workflow: parked task",
slog.Int("attempt", attempt), slog.Bool("permanent", permanent),
slog.Any("error", err))
m.signal(runID, StatusStuck)
return StatusStuck
}
taskLog.WarnContext(ctx, "workflow: task attempt failed",
slog.Int("attempt", attempt), slog.Int("budget", budget),
slog.Duration("backoff", backoff), slog.Any("error", err))
if backoff <= m.cfg.MaxInProcessBackoff {
persisted, perr := m.db.PersistTaskFailure(ctx, runID, seq, failure, backoff, false, claimID)
if perr != nil {
return m.hold(ctx, taskLog, "workflow: persist task failure failed", perr)
}
if !persisted {
return StatusRunning
}
// Mirror the persisted attempt_count bump so the next backoff matches
// what a resumed executor would compute.
task.AttemptCount++
if !sleepCtx(ctx, backoff) {
continue
}
if !m.heartbeat(ctx, runID, seq, claimID) {
return StatusRunning
}
continue
}
if _, perr := m.db.PersistTaskFailure(ctx, runID, seq, failure, backoff, true, claimID); perr != nil {
return m.hold(ctx, taskLog, "workflow: persist task failure (release) failed", perr)
}
return StatusRunning
}
}
// abandon releases the claim when a write failed before the handler ran. No
// attempt was spent, so the next poll tick can re-claim the run instead of
// waiting for the sweeper.
func (m *Manager) abandon(ctx context.Context, logger *slog.Logger, msg string, err error, runID uuid.UUID, seq int16, claimID uuid.UUID) Status {
logger.ErrorContext(ctx, msg, slog.Any("error", err))
m.release(ctx, runID, seq, claimID)
return StatusRunning
}
// hold keeps the claim when a write failed after the handler ran. The attempt
// was spent but never recorded, so the run must wait for SweepStuck, which
// bumps attempt_count on reclaim, before anything re-runs the task.
func (m *Manager) hold(ctx context.Context, logger *slog.Logger, msg string, err error) Status {
logger.ErrorContext(ctx, msg, slog.Any("error", err))
return StatusRunning
}
// heartbeat releases the claim when the refresh fails, so the next tick picks
// the run up instead of it waiting out SweepMaxAge and taking a SweepStuck
// attempt bump.
func (m *Manager) heartbeat(ctx context.Context, runID uuid.UUID, seq int16, claimID uuid.UUID) bool {
ok, err := m.db.Heartbeat(ctx, runID, claimID)
if err != nil {
m.log.ErrorContext(ctx, "workflow: heartbeat failed",
slog.String("run_id", runID.String()), slog.Any("error", err))
}
if ok {
return true
}
m.release(ctx, runID, seq, claimID)
return false
}
func (m *Manager) release(ctx context.Context, runID uuid.UUID, seq int16, claimID uuid.UUID) {
if err := m.db.ReleaseClaim(ctx, runID, seq, claimID); err != nil {
m.log.ErrorContext(ctx, "workflow: release claim failed",
slog.String("run_id", runID.String()), slog.Int("seq", int(seq)), slog.Any("error", err))
}
}
func priorOutputs(tasks []*store.TaskRun) map[string]json.RawMessage {
outputs := make(map[string]json.RawMessage, len(tasks))
for _, s := range tasks {
if s.Status == store.TaskStatusSucceeded && s.Output != nil {
outputs[s.Handler] = s.Output
}
}
return outputs
}
func nextBackoff(sc taskConfig, cfg Config, attempt int) time.Duration {
if len(sc.backoffSchedule) > 0 {
return scheduleBackoff(sc.backoffSchedule, attempt)
}
return computeBackoff(
cmp.Or(sc.backoffBase, cfg.BackoffBase),
cmp.Or(sc.backoffMax, cfg.BackoffMax),
attempt,
)
}
// scheduleBackoff takes attempt from the persisted count, so a resumed
// executor picks the same delay. Register rejects an empty schedule.
func scheduleBackoff(schedule []time.Duration, attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > len(schedule) {
attempt = len(schedule)
}
return schedule[attempt-1]
}
// computeBackoff returns min(base*2^(attempt-1), max). It doubles iteratively
// so an overflow clamps to max.
func computeBackoff(base, max time.Duration, attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
backoff := base
for range attempt - 1 {
backoff *= 2
if backoff <= 0 || backoff >= max {
return max
}
}
if backoff > max {
return max
}
return backoff
}
// sleepCtx returns false when ctx is done before d elapses.
func sleepCtx(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
}
}