Finish pkg/scheduler

This commit is contained in:
Fedor Korotkiy 2020-04-05 14:29:46 +03:00
parent 1d5c64d8ca
commit e56f1df9ba
8 changed files with 296 additions and 211 deletions

View file

@ -38,10 +38,6 @@ type HeartbeatRequest struct {
// в данный момент. // в данный момент.
RunningJobs []build.ID RunningJobs []build.ID
DownloadingSources []build.ID
DownloadingArtifacts []build.ID
// FreeSlots сообщаяет, сколько еще процессов можно запустить на этом воркере. // FreeSlots сообщаяет, сколько еще процессов можно запустить на этом воркере.
FreeSlots int FreeSlots int
@ -51,9 +47,6 @@ type HeartbeatRequest struct {
// AddedArtifacts говорит, какие артефакты появились в кеше на этой итерации цикла. // AddedArtifacts говорит, какие артефакты появились в кеше на этой итерации цикла.
AddedArtifacts []build.ID AddedArtifacts []build.ID
// AddedSourceFiles говорит, какие файлы появились в кеше на этой итерации цикла.
AddedSourceFiles []build.ID
} }
// JobSpec описывает джоб, который нужно запустить. // JobSpec описывает джоб, который нужно запустить.

View file

@ -111,7 +111,7 @@ func (c *Coordinator) Heartbeat(ctx context.Context, req *api.HeartbeatRequest)
JobsToRun: map[build.ID]api.JobSpec{}, JobsToRun: map[build.ID]api.JobSpec{},
} }
job := c.scheduler.PickJob(req.WorkerID, ctx.Done()) job := c.scheduler.PickJob(ctx, req.WorkerID)
if job != nil { if job != nil {
rsp.JobsToRun[job.Job.ID] = *job.Job rsp.JobsToRun[job.Job.ID] = *job.Job
} }

View file

@ -0,0 +1,41 @@
# scheduler
Пакет `scheduler` реализует планировщик системы. `scheduler.Scheduler` хранит полное состояние кластера
и принимает решение на каком воркере и какой джоб нужно запустить.
Шедулер является точкой координации между воркерами и билдами. Бегущие билды обращаются к шедулеру,
передавая джобы в функцию `ScheduleJob`. Воркеры забирают джобы из шедулера вызывая функцию `PickJob`.
Вы можете отложить реализацию полной версии шедулера на последний шаг, и реализовать упрощённую версию
на одном глобальном канале. Такой реализации будет достаточно, чтобы работали все интеграционные тесты с одним
воркером.
## Алгоритм планирования
Планировщик поддерживает множество очередей:
1. Одна глобальная очередь
2. По две локальные очереди на воркер.
При запросе нового джоба воркер выбирает случайную джобу из трех очередей - глобальной, и двух локальных относящихся
к этому воркеру. Случайная очередь выбирается одним вызовом `select {}`.
Ожидающий исполнения джоб всегда находится в первой локальной очереди воркеров, на которых есть
результаты работы этого джоба.
Если джоб ждёт выполнения дольше `CacheTimeout` или если в момент `SchedulerJob` джоба небыло в кеше ни на одном
из воркеров, то он находится во всех вторых локальных очередях воркеров, на которых есть хотябы один артефакт
из множества зависимостей этого джоба.
Определения первой и второй локальной очереди не зависят от того, в каком порядке в шедулер пришли джобы
и информация о кеше артефактов. Тоесть, если джоб уже находится в глобальной очереди, и в этот момент приходит
информация, что этот джоб находится в кеше на `W0`, то джоб должен быть добавлен
в первую локальную очередь `W0`.
Если джоб ждёт выполнения дольше `DepTimeout`, то он помещается в глобальную очередь.
## Тестирование
Вместо реального времени, юниттесты шедулера используют библиотеку `clockwork`. Это накладывает ограничения
на детали вашей реализации. Ожидание `CacheTimeout` и `DepTimeout` должно быть реализовано как `select {}` на
канале, который вернула функция `timeAfter`. Мы считаем что `CacheTimeout > DepTimeout`, и ожидание этих
таймаутов происходит последовательно в одной горутине.

View file

@ -0,0 +1,3 @@
package scheduler
var TimeAfter = &timeAfter

View file

@ -1,6 +1,7 @@
package scheduler package scheduler
import ( import (
"context"
"sync" "sync"
"time" "time"
@ -12,8 +13,8 @@ import (
type PendingJob struct { type PendingJob struct {
Job *api.JobSpec Job *api.JobSpec
Result *api.JobResult
Finished chan struct{} Finished chan struct{}
Result *api.JobResult
mu sync.Mutex mu sync.Mutex
pickedUp chan struct{} pickedUp chan struct{}
@ -37,29 +38,16 @@ func (p *PendingJob) pickUp() bool {
} }
} }
type jobQueue struct { func (p *PendingJob) enqueue(q chan *PendingJob) {
mu sync.Mutex select {
jobs []*PendingJob case q <- p:
} case <-p.pickedUp:
func (q *jobQueue) put(job *PendingJob) {
q.mu.Lock()
defer q.mu.Unlock()
q.jobs = append(q.jobs, job)
}
func (q *jobQueue) pop() *PendingJob {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.jobs) == 0 {
return nil
} }
}
job := q.jobs[0] type workerQueue struct {
q.jobs = q.jobs[1:] cacheQueue chan *PendingJob
return job depQueue chan *PendingJob
} }
type Config struct { type Config struct {
@ -73,12 +61,13 @@ type Scheduler struct {
mu sync.Mutex mu sync.Mutex
cachedJobs map[build.ID]map[api.WorkerID]struct{} cachedJobs map[build.ID]map[api.WorkerID]struct{}
pendingJobs map[build.ID]*PendingJob
cacheLocalQueue map[api.WorkerID]*jobQueue pendingJobs map[build.ID]*PendingJob
depLocalQueue map[api.WorkerID]*jobQueue pendingJobDeps map[build.ID]map[*PendingJob]struct{}
globalQueue chan *PendingJob
workerQueue map[api.WorkerID]*workerQueue
globalQueue chan *PendingJob
} }
func NewScheduler(l *zap.Logger, config Config) *Scheduler { func NewScheduler(l *zap.Logger, config Config) *Scheduler {
@ -86,12 +75,12 @@ func NewScheduler(l *zap.Logger, config Config) *Scheduler {
l: l, l: l,
config: config, config: config,
cachedJobs: make(map[build.ID]map[api.WorkerID]struct{}), cachedJobs: make(map[build.ID]map[api.WorkerID]struct{}),
pendingJobs: make(map[build.ID]*PendingJob), pendingJobs: make(map[build.ID]*PendingJob),
pendingJobDeps: make(map[build.ID]map[*PendingJob]struct{}),
cacheLocalQueue: make(map[api.WorkerID]*jobQueue), workerQueue: make(map[api.WorkerID]*workerQueue),
depLocalQueue: make(map[api.WorkerID]*jobQueue), globalQueue: make(chan *PendingJob),
globalQueue: make(chan *PendingJob),
} }
} }
@ -99,13 +88,15 @@ func (c *Scheduler) RegisterWorker(workerID api.WorkerID) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
_, ok := c.cacheLocalQueue[workerID] _, ok := c.workerQueue[workerID]
if ok { if ok {
return return
} }
c.cacheLocalQueue[workerID] = new(jobQueue) c.workerQueue[workerID] = &workerQueue{
c.depLocalQueue[workerID] = new(jobQueue) cacheQueue: make(chan *PendingJob),
depQueue: make(chan *PendingJob),
}
} }
func (c *Scheduler) OnJobComplete(workerID api.WorkerID, jobID build.ID, res *api.JobResult) bool { func (c *Scheduler) OnJobComplete(workerID api.WorkerID, jobID build.ID, res *api.JobResult) bool {
@ -124,6 +115,11 @@ func (c *Scheduler) OnJobComplete(workerID api.WorkerID, jobID build.ID, res *ap
} }
job[workerID] = struct{}{} job[workerID] = struct{}{}
workerQueue := c.workerQueue[workerID]
for waiter := range c.pendingJobDeps[jobID] {
go waiter.enqueue(workerQueue.depQueue)
}
c.mu.Unlock() c.mu.Unlock()
if !pendingFound { if !pendingFound {
@ -135,41 +131,38 @@ func (c *Scheduler) OnJobComplete(workerID api.WorkerID, jobID build.ID, res *ap
return true return true
} }
func (c *Scheduler) findOptimalWorkers(jobID build.ID, deps []build.ID) (cacheLocal, depLocal []api.WorkerID) { func (c *Scheduler) enqueueCacheLocal(job *PendingJob) bool {
depLocalSet := map[api.WorkerID]struct{}{} cached := false
c.mu.Lock() for workerID := range c.cachedJobs[job.Job.ID] {
defer c.mu.Unlock() cached = true
go job.enqueue(c.workerQueue[workerID].cacheQueue)
for workerID := range c.cachedJobs[jobID] {
cacheLocal = append(cacheLocal, workerID)
} }
for _, dep := range deps { return cached
for workerID := range c.cachedJobs[dep] {
if _, ok := depLocalSet[workerID]; !ok {
depLocal = append(depLocal, workerID)
depLocalSet[workerID] = struct{}{}
}
}
}
return
} }
var timeAfter = time.After var timeAfter = time.After
func (c *Scheduler) doScheduleJob(job *PendingJob) { func (c *Scheduler) putDepQueue(job *PendingJob, dep build.ID) {
cacheLocal, depLocal := c.findOptimalWorkers(job.Job.ID, job.Job.Deps) depJobs, ok := c.pendingJobDeps[dep]
if !ok {
depJobs = make(map[*PendingJob]struct{})
c.pendingJobDeps[dep] = depJobs
}
depJobs[job] = struct{}{}
}
if len(cacheLocal) != 0 { func (c *Scheduler) deleteDepQueue(job *PendingJob, dep build.ID) {
c.mu.Lock() depJobs := c.pendingJobDeps[dep]
for _, workerID := range cacheLocal { delete(depJobs, job)
c.cacheLocalQueue[workerID].put(job) if len(depJobs) == 0 {
} delete(c.pendingJobDeps, dep)
c.mu.Unlock() }
}
c.l.Debug("job is put into cache-local queues", zap.String("job_id", job.Job.ID.String())) func (c *Scheduler) doScheduleJob(job *PendingJob, cached bool) {
if cached {
select { select {
case <-job.pickedUp: case <-job.pickedUp:
c.l.Debug("job picked", zap.String("job_id", job.Job.ID.String())) c.l.Debug("job picked", zap.String("job_id", job.Job.ID.String()))
@ -178,31 +171,51 @@ func (c *Scheduler) doScheduleJob(job *PendingJob) {
} }
} }
if len(depLocal) != 0 { c.mu.Lock()
workers := make(map[api.WorkerID]struct{})
for _, dep := range job.Job.Deps {
c.putDepQueue(job, dep)
for workerID := range c.cachedJobs[dep] {
if _, ok := workers[workerID]; ok {
return
}
go job.enqueue(c.workerQueue[workerID].depQueue)
workers[workerID] = struct{}{}
}
}
c.mu.Unlock()
defer func() {
c.mu.Lock() c.mu.Lock()
for _, workerID := range depLocal { defer c.mu.Unlock()
c.depLocalQueue[workerID].put(job)
}
c.mu.Unlock()
c.l.Debug("job is put into dep-local queues", zap.String("job_id", job.Job.ID.String())) for _, dep := range job.Job.Deps {
select { c.deleteDepQueue(job, dep)
case <-job.pickedUp:
c.l.Debug("job picked", zap.String("job_id", job.Job.ID.String()))
return
case <-timeAfter(c.config.DepsTimeout):
} }
} }()
c.l.Debug("job is put into dep-local queues", zap.String("job_id", job.Job.ID.String()))
c.l.Debug("job is put into global queue", zap.String("job_id", job.Job.ID.String()))
select { select {
case c.globalQueue <- job:
case <-job.pickedUp: case <-job.pickedUp:
c.l.Debug("job picked", zap.String("job_id", job.Job.ID.String()))
return
case <-timeAfter(c.config.DepsTimeout):
} }
go job.enqueue(c.globalQueue)
c.l.Debug("job is put into global queue", zap.String("job_id", job.Job.ID.String()))
<-job.pickedUp
c.l.Debug("job picked", zap.String("job_id", job.Job.ID.String())) c.l.Debug("job picked", zap.String("job_id", job.Job.ID.String()))
} }
func (c *Scheduler) ScheduleJob(job *api.JobSpec) *PendingJob { func (c *Scheduler) ScheduleJob(job *api.JobSpec) *PendingJob {
var cached bool
c.mu.Lock() c.mu.Lock()
pendingJob, running := c.pendingJobs[job.ID] pendingJob, running := c.pendingJobs[job.ID]
if !running { if !running {
@ -214,12 +227,13 @@ func (c *Scheduler) ScheduleJob(job *api.JobSpec) *PendingJob {
} }
c.pendingJobs[job.ID] = pendingJob c.pendingJobs[job.ID] = pendingJob
cached = c.enqueueCacheLocal(pendingJob)
} }
c.mu.Unlock() c.mu.Unlock()
if !running { if !running {
c.l.Debug("job is scheduled", zap.String("job_id", job.ID.String())) c.l.Debug("job is scheduled", zap.String("job_id", job.ID.String()))
go c.doScheduleJob(pendingJob) go c.doScheduleJob(pendingJob, cached)
} else { } else {
c.l.Debug("job is pending", zap.String("job_id", job.ID.String())) c.l.Debug("job is pending", zap.String("job_id", job.ID.String()))
} }
@ -227,50 +241,37 @@ func (c *Scheduler) ScheduleJob(job *api.JobSpec) *PendingJob {
return pendingJob return pendingJob
} }
func (c *Scheduler) PickJob(workerID api.WorkerID, canceled <-chan struct{}) *PendingJob { func (c *Scheduler) PickJob(ctx context.Context, workerID api.WorkerID) *PendingJob {
c.l.Debug("picking next job", zap.String("worker_id", workerID.String())) c.l.Debug("picking next job", zap.String("worker_id", workerID.String()))
var cacheLocal, depLocal *jobQueue
c.mu.Lock() c.mu.Lock()
cacheLocal = c.cacheLocalQueue[workerID] local := c.workerQueue[workerID]
depLocal = c.depLocalQueue[workerID]
c.mu.Unlock() c.mu.Unlock()
for { var pg *PendingJob
job := cacheLocal.pop() var queue string
if job == nil {
break
}
if job.pickUp() {
c.l.Debug("picked job from cache-local queue", zap.String("worker_id", workerID.String()), zap.String("job_id", job.Job.ID.String()))
return job
}
}
for {
job := depLocal.pop()
if job == nil {
break
}
if job.pickUp() {
c.l.Debug("picked job from dep-local queue", zap.String("worker_id", workerID.String()), zap.String("job_id", job.Job.ID.String()))
return job
}
}
for { for {
select { select {
case job := <-c.globalQueue: case pg = <-c.globalQueue:
if job.pickUp() { queue = "global"
c.l.Debug("picked job from global queue", zap.String("worker_id", workerID.String()), zap.String("job_id", job.Job.ID.String())) case pg = <-local.depQueue:
return job queue = "dep"
} case pg = <-local.cacheQueue:
queue = "cache"
case <-canceled: case <-ctx.Done():
return nil return nil
} }
if pg.pickUp() {
break
}
} }
c.l.Debug("picked job",
zap.String("worker_id", workerID.String()),
zap.String("job_id", pg.Job.ID.String()),
zap.String("queue", queue))
return pg
} }

View file

@ -1,113 +1,137 @@
package scheduler package scheduler_test
import ( import (
"context"
"testing" "testing"
"time" "time"
"github.com/jonboulle/clockwork" "github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/goleak" "go.uber.org/goleak"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"gitlab.com/slon/shad-go/distbuild/pkg/api" "gitlab.com/slon/shad-go/distbuild/pkg/api"
"gitlab.com/slon/shad-go/distbuild/pkg/build" "gitlab.com/slon/shad-go/distbuild/pkg/build"
"gitlab.com/slon/shad-go/distbuild/pkg/scheduler"
) )
const ( const (
workerID0 api.WorkerID = "w0" workerID0 api.WorkerID = "w0"
) )
func TestScheduler(t *testing.T) { var (
defer goleak.VerifyNone(t) config = scheduler.Config{
clock := clockwork.NewFakeClock()
timeAfter = clock.After
defer func() { timeAfter = time.After }()
config := Config{
CacheTimeout: time.Second, CacheTimeout: time.Second,
DepsTimeout: time.Minute, DepsTimeout: time.Minute,
} }
)
t.Run("SingleJob", func(t *testing.T) { type testScheduler struct {
s := NewScheduler(zaptest.NewLogger(t), config) *scheduler.Scheduler
clockwork.FakeClock
job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} }
pendingJob0 := s.ScheduleJob(job0)
func newTestScheduler(t *testing.T) *testScheduler {
s.RegisterWorker(workerID0) log := zaptest.NewLogger(t)
pickerJob := s.PickJob(workerID0, nil)
s := &testScheduler{
require.Equal(t, pendingJob0, pickerJob) FakeClock: clockwork.NewFakeClock(),
Scheduler: scheduler.NewScheduler(log, config),
result := &api.JobResult{ID: job0.ID, ExitCode: 0} }
s.OnJobComplete(workerID0, job0.ID, result)
*scheduler.TimeAfter = s.FakeClock.After
select { return s
case <-pendingJob0.Finished: }
require.Equal(t, pendingJob0.Result, result)
func (s *testScheduler) stop(t *testing.T) {
default: *scheduler.TimeAfter = time.After
t.Fatalf("job0 is not finished") goleak.VerifyNone(t)
} }
})
func TestScheduler_SingleJob(t *testing.T) {
t.Run("PickJobTimeout", func(t *testing.T) { s := newTestScheduler(t)
s := NewScheduler(zaptest.NewLogger(t), config) defer s.stop(t)
canceled := make(chan struct{}) job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}}
close(canceled) pendingJob0 := s.ScheduleJob(job0)
s.RegisterWorker(workerID0) s.BlockUntil(1)
require.Nil(t, s.PickJob(workerID0, canceled)) s.Advance(config.DepsTimeout) // At this point job must be in global queue.
})
s.RegisterWorker(workerID0)
t.Run("CacheLocalScheduling", func(t *testing.T) { pickerJob := s.PickJob(context.Background(), workerID0)
s := NewScheduler(zaptest.NewLogger(t), config)
require.Equal(t, pendingJob0, pickerJob)
job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}}
job1 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} result := &api.JobResult{ID: job0.ID, ExitCode: 0}
s.OnJobComplete(workerID0, job0.ID, result)
s.RegisterWorker(workerID0)
s.OnJobComplete(workerID0, job0.ID, &api.JobResult{}) select {
case <-pendingJob0.Finished:
pendingJob1 := s.ScheduleJob(job1) require.Equal(t, pendingJob0.Result, result)
pendingJob0 := s.ScheduleJob(job0)
default:
// job0 scheduling should be blocked on CacheTimeout t.Fatalf("job0 is not finished")
clock.BlockUntil(1) }
}
pickedJob := s.PickJob(workerID0, nil)
require.Equal(t, pendingJob0, pickedJob) func TestScheduler_PickJobCancelation(t *testing.T) {
s := newTestScheduler(t)
pickedJob = s.PickJob(workerID0, nil) defer s.stop(t)
require.Equal(t, pendingJob1, pickedJob)
ctx, cancel := context.WithCancel(context.Background())
clock.Advance(time.Hour) cancel()
})
s.RegisterWorker(workerID0)
t.Run("DependencyLocalScheduling", func(t *testing.T) { require.Nil(t, s.PickJob(ctx, workerID0))
s := NewScheduler(zaptest.NewLogger(t), config) }
job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} func TestScheduler_CacheLocalScheduling(t *testing.T) {
job1 := &api.JobSpec{Job: build.Job{ID: build.NewID(), Deps: []build.ID{job0.ID}}} s := newTestScheduler(t)
job2 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} defer s.stop(t)
s.RegisterWorker(workerID0) cachedJob := &api.JobSpec{Job: build.Job{ID: build.NewID()}}
s.OnJobComplete(workerID0, job0.ID, &api.JobResult{}) uncachedJob := &api.JobSpec{Job: build.Job{ID: build.NewID()}}
pendingJob2 := s.ScheduleJob(job2) s.RegisterWorker(workerID0)
pendingJob1 := s.ScheduleJob(job1) s.OnJobComplete(workerID0, cachedJob.ID, &api.JobResult{})
// job1 should be blocked on DepsTimeout pendingUncachedJob := s.ScheduleJob(uncachedJob)
clock.BlockUntil(1) pendingCachedJob := s.ScheduleJob(cachedJob)
pickedJob := s.PickJob(workerID0, nil) s.BlockUntil(2) // both jobs should be blocked
require.Equal(t, pendingJob1, pickedJob)
firstPickedJob := s.PickJob(context.Background(), workerID0)
pickedJob = s.PickJob(workerID0, nil) assert.Equal(t, pendingCachedJob, firstPickedJob)
require.Equal(t, pendingJob2, pickedJob)
s.Advance(config.DepsTimeout) // At this point uncachedJob is put into global queue.
clock.Advance(time.Hour)
}) secondPickedJob := s.PickJob(context.Background(), workerID0)
assert.Equal(t, pendingUncachedJob, secondPickedJob)
}
func TestScheduler_DependencyLocalScheduling(t *testing.T) {
s := newTestScheduler(t)
defer s.stop(t)
job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}}
s.RegisterWorker(workerID0)
s.OnJobComplete(workerID0, job0.ID, &api.JobResult{})
job1 := &api.JobSpec{Job: build.Job{ID: build.NewID(), Deps: []build.ID{job0.ID}}}
job2 := &api.JobSpec{Job: build.Job{ID: build.NewID()}}
pendingJob2 := s.ScheduleJob(job2)
pendingJob1 := s.ScheduleJob(job1)
s.BlockUntil(2) // both jobs should be blocked on DepsTimeout
firstPickedJob := s.PickJob(context.Background(), workerID0)
require.Equal(t, pendingJob1, firstPickedJob)
s.Advance(config.DepsTimeout) // At this point job2 is put into global queue.
secondPickedJob := s.PickJob(context.Background(), workerID0)
require.Equal(t, pendingJob2, secondPickedJob)
} }

View file

@ -4,11 +4,13 @@ import (
"context" "context"
"errors" "errors"
"gitlab.com/slon/shad-go/distbuild/pkg/api"
"gitlab.com/slon/shad-go/distbuild/pkg/artifact"
"gitlab.com/slon/shad-go/distbuild/pkg/build" "gitlab.com/slon/shad-go/distbuild/pkg/build"
"gitlab.com/slon/shad-go/distbuild/pkg/filecache" "gitlab.com/slon/shad-go/distbuild/pkg/filecache"
) )
func (w *Worker) pullFiles(ctx context.Context, files map[build.ID]string) error { func (w *Worker) downloadFiles(ctx context.Context, files map[build.ID]string) error {
for id := range files { for id := range files {
_, unlock, err := w.fileCache.Get(id) _, unlock, err := w.fileCache.Get(id)
if errors.Is(err, filecache.ErrNotFound) { if errors.Is(err, filecache.ErrNotFound) {
@ -24,3 +26,20 @@ func (w *Worker) pullFiles(ctx context.Context, files map[build.ID]string) error
return nil return nil
} }
func (w *Worker) downloadArtifacts(ctx context.Context, artifacts map[build.ID]api.WorkerID) error {
for id, worker := range artifacts {
_, unlock, err := w.artifacts.Get(id)
if errors.Is(err, artifact.ErrNotFound) {
if err = artifact.Download(ctx, worker.String(), w.artifacts, id); err != nil {
return err
}
} else if err != nil {
return err
} else {
unlock()
}
}
return nil
}

View file

@ -165,7 +165,11 @@ func (w *Worker) runJob(ctx context.Context, spec *api.JobSpec) (*api.JobResult,
return res, nil return res, nil
} }
if err = w.pullFiles(ctx, spec.SourceFiles); err != nil { if err = w.downloadFiles(ctx, spec.SourceFiles); err != nil {
return nil, err
}
if err := w.downloadArtifacts(ctx, spec.Artifacts); err != nil {
return nil, err return nil, err
} }