···1818var (
1919 ErrTimedOut = errors.New("timed out")
2020 ErrWorkflowFailed = errors.New("workflow failed")
2121+ ErrCancelled = errors.New("workflow cancelled")
2122)
22232323-type workflowFinalizer interface {
2424- FinalizeWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error
2424+// workflowLoggerProvider lets an engine supply the WorkflowLogger StartWorkflows
2525+// uses, instead of the default on-disk one. Engines that don't implement it get
2626+// a file logger under cfg.Server.LogDir. The mill engine supplies a no-op logger
2727+// because it doesn't run steps locally — it relays the executor's real log file
2828+// into place itself, so a second logger here would only write competing lines.
2929+type workflowLoggerProvider interface {
3030+ WorkflowLogger(wid models.WorkflowId) models.WorkflowLogger
3131+}
3232+3333+func reportWorkflowStatusError(l *slog.Logger, database *db.DB, n *notifier.Notifier, wid models.WorkflowId, err error) {
3434+ if errors.Is(err, ErrTimedOut) {
3535+ dbErr := database.StatusTimeout(wid, n)
3636+ if dbErr != nil {
3737+ l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
3838+ }
3939+ } else if errors.Is(err, ErrCancelled) {
4040+ dbErr := database.StatusCancelled(wid, err.Error(), -1, n)
4141+ if dbErr != nil {
4242+ l.Error("failed to set workflow status to cancelled", "wid", wid, "err", dbErr)
4343+ }
4444+ } else {
4545+ dbErr := database.StatusFailed(wid, err.Error(), -1, n)
4646+ if dbErr != nil {
4747+ l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
4848+ }
4949+ }
2550}
26512752func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
···6893 }
6994 }()
70957171- wfLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues)
7272- if err != nil {
9696+ var wfLogger models.WorkflowLogger
9797+ if p, ok := eng.(workflowLoggerProvider); ok {
9898+ wfLogger = p.WorkflowLogger(wid)
9999+ } else if fileLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues); err != nil {
73100 l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
74101 wfLogger = models.NullLogger{}
75102 } else {
76103 l.Info("setup step logger; logs will be persisted", "logDir", cfg.Server.LogDir, "wid", wid)
7777- defer wfLogger.Close()
104104+ wfLogger = fileLogger
105105+ defer fileLogger.Close()
78106 }
7910780108 l.Info("waiting for slot", "wid", wid)
81109 slot := WorkflowSlot(NoopSlot{})
82110 if s, ok := eng.(WorkflowSlotter); ok {
83111 var err error
8484- slot, err = s.AcquireWorkflowSlot(ctx, wid, &w)
112112+ slot, err = s.AcquireWorkflowSlot(ctx, wid, &w, Wait)
85113 if err != nil {
86114 l.Error("failed to acquire slot", "wid", wid, "err", err)
8787- dbErr := db.StatusFailed(wid, err.Error(), -1, n)
8888- if dbErr != nil {
8989- l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
9090- }
115115+ reportWorkflowStatusError(l, db, n, wid, err)
91116 return
92117 }
93118 }
94119 defer slot.Release()
951209696- err = db.StatusRunning(wid, n)
121121+ err := db.StatusRunning(wid, n)
97122 if err != nil {
98123 l.Error("failed to set workflow status to running", "wid", wid, "err", err)
99124 return
···110135 l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
111136 }
112137113113- dbErr := db.StatusFailed(wid, err.Error(), -1, n)
114114- if dbErr != nil {
115115- l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
116116- }
138138+ reportWorkflowStatusError(l, db, n, wid, err)
117139 return
118140 }
119141 defer eng.DestroyWorkflow(ctx, wid)
···139161 }
140162141163 if err != nil {
142142- if errors.Is(err, ErrTimedOut) {
143143- dbErr := db.StatusTimeout(wid, n)
144144- if dbErr != nil {
145145- l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
146146- }
147147- } else {
148148- dbErr := db.StatusFailed(wid, err.Error(), -1, n)
149149- if dbErr != nil {
150150- l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
151151- }
152152- }
153153- return
154154- }
155155- }
156156-157157- if finalizer, ok := eng.(workflowFinalizer); ok {
158158- if err := finalizer.FinalizeWorkflow(ctx, wid, &w, wfLogger); err != nil {
159159- dbErr := db.StatusFailed(wid, err.Error(), -1, n)
160160- if dbErr != nil {
161161- l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
162162- }
164164+ reportWorkflowStatusError(l, db, n, wid, err)
163165 return
164166 }
165167 }
+14-7
spindle/engine/scheduler.go
···5050 }
5151}
52525353-func (s *ResourceScheduler[R]) Acquire(ctx context.Context, req R) (WorkflowSlot, error) {
5353+// Acquire reserves resources for req. With Wait it blocks (queueing with aging)
5454+// until the request fits; with NoWait it tries once and returns
5555+// ErrNoWorkflowSlots if it can't be served right now, never queueing (the mill
5656+// owns the backlog, so a NoWait caller either has room this instant or says no).
5757+func (s *ResourceScheduler[R]) Acquire(ctx context.Context, req R, mode AcquireMode) (WorkflowSlot, error) {
5458 if s == nil {
5559 return NoopSlot{}, nil
5660 }
···6064 s.mu.Unlock()
6165 return nil, fmt.Errorf("%w: request=%v budget=%v max=%v", ErrNoWorkflowSlots, req, s.budget, s.max)
6266 }
6363- if len(s.queue) == 0 && s.used.Add(req).Fits(s.budget) {
6767+ // can serve immediately? a NoWait caller ignores the queue (it never waits
6868+ // behind aged waiters); a Wait caller only jumps in when the queue is empty.
6969+ if s.used.Add(req).Fits(s.budget) && (mode == NoWait || len(s.queue) == 0) {
6470 s.used = s.used.Add(req)
6571 s.mu.Unlock()
6672 return &resourceLease[R]{scheduler: s, req: req}, nil
7373+ }
7474+ if mode == NoWait {
7575+ used := s.used
7676+ s.mu.Unlock()
7777+ return nil, fmt.Errorf("%w: request=%v used=%v budget=%v", ErrNoWorkflowSlots, req, used, s.budget)
6778 }
68796980 waiter := &resourceWaiter[R]{req: req, ready: make(chan struct{}), enqueuedAt: s.now()}
···129140}
130141131142func (s *ResourceScheduler[R]) remove(waiter *resourceWaiter[R]) {
132132- for i, candidate := range s.queue {
133133- if candidate != waiter {
134134- continue
135135- }
143143+ if i := slices.Index(s.queue, waiter); i >= 0 {
136144 s.queue = slices.Delete(s.queue, i, i+1)
137137- return
138145 }
139146}
···8899 "gopkg.in/yaml.v3"
1010 "tangled.org/core/api/tangled"
1111+ "tangled.org/core/spindle/engine"
1112 "tangled.org/core/spindle/models"
1213 "tangled.org/core/spindle/secrets"
1314)
···77787879func (e *DummyEngine) WorkflowTimeout() time.Duration {
7980 return 5 * time.Minute
8181+}
8282+8383+// AcquireWorkflowSlot lets the dummy engine satisfy WorkflowSlotter (so it can
8484+// run standalone or be driven by the executor shim). The dummy has no capacity
8585+// limit, so it always grants a no-op slot regardless of mode.
8686+func (e *DummyEngine) AcquireWorkflowSlot(_ context.Context, _ models.WorkflowId, _ *models.Workflow, _ engine.AcquireMode) (engine.WorkflowSlot, error) {
8787+ return engine.NoopSlot{}, nil
8088}
81898290func (e *DummyEngine) DestroyWorkflow(_ context.Context, wid models.WorkflowId) error {
+2-5
spindle/engines/microvm/budget.go
···6666 return budget, maxReq, cfg.AgingThreshold
6767}
68686969-func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) {
6969+func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, mode engine.AcquireMode) (engine.WorkflowSlot, error) {
7070 state, ok := wf.Data.(*workflowState)
7171 if !ok || state == nil {
7272 return nil, fmt.Errorf("microVM workflow state is not initialized")
···7575 return engine.NoopSlot{}, nil
7676 }
7777 req := resourcesForImage(state.ImageSpec)
7878- if req.MemoryMiB < 0 || req.VCPUs < 0 || req.DiskMiB < 0 {
7979- return nil, fmt.Errorf("microVM resource request must not be negative: %s", req)
8080- }
8181- return e.scheduler.Acquire(ctx, req)
7878+ return e.scheduler.Acquire(ctx, req, mode)
8279}
83808481func resourcesForImage(spec ImageSpec) Resources {
-4
spindle/engines/microvm/engine.go
···542542 return cleanupErr
543543}
544544545545-func (e *Engine) FinalizeWorkflow(ctx context.Context, wid models.WorkflowId, w *models.Workflow, wfLogger models.WorkflowLogger) error {
546546- return nil
547547-}
548548-549545func (e *Engine) WorkflowTimeout() time.Duration {
550546 d, err := time.ParseDuration(e.cfg.MicroVMPipelines.WorkflowTimeout)
551547 if err != nil {