aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/worker_pool.go
blob: fe44644946f3d1caeec1d6f78d46c6a6ecc66816 (plain) (blame)
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
package cli

import (
	"context"
	"errors"
	"runtime"
	"sync"
	"sync/atomic"
)

// pool is a bounded worker pool for concurrent, independent tasks (§14). A
// single caller feeds work via submit; up to concurrency tasks run in flight at
// once, bounded by the sem token held from submit until the task returns.
//
// The pool is deliberately upload-agnostic: a task is any func(ctx) error, so
// the same machinery serves push uploads today and prune deletes later. Task
// wording, error identity, and any success side-effect live in the closure.
//
// submit is the only place that logs a per-action line, and it is only ever
// called from one goroutine (the caller feeding work), so output needs no
// locking; only the shared counters are guarded. Tasks run concurrently, so any
// state a task closure touches beyond its own arguments is the caller's
// responsibility to synchronize.
type workerPool struct {
	ctx    context.Context
	cancel context.CancelCauseFunc
	sem    chan struct{}
	wg     sync.WaitGroup

	// failFast cancels the pool on the first failure so in-flight tasks are
	// abandoned and queued ones never start (the push-asset policy, §8.3). When
	// false, every submitted task runs and all failures are collected (the
	// commit/prune-delete policy).
	failFast bool

	mtx       sync.Mutex
	errs      []error
	succeeded int64
}

// newWorkerPool creates a new worker pool to process tasks concurrently. If `concurrency`
// is zero, the number of parallel jobs is aut-detected based on the number of CPUs.
func newWorkerPool(ctx context.Context, concurrency uint, failFast bool) *workerPool {
	if concurrency == 0 {
		concurrency = 2 * uint(runtime.NumCPU())
	}
	ctx, cancel := context.WithCancelCause(ctx)
	return &workerPool{
		ctx:      ctx,
		cancel:   cancel,
		sem:      make(chan struct{}, concurrency),
		failFast: failFast,
	}
}

// submit blocks until a worker slot is free, then runs task in the background.
// It reports false when the pool has been cancelled (fail-fast or the parent
// context), signalling the caller to stop feeding work. action is the text that
// will be logged before the task is executed.
func (p *workerPool) submit(action string, task func(ctx context.Context) error) bool {
	select {
	case p.sem <- struct{}{}:
	case <-p.ctx.Done():
		return false
	}

	logAction(action)

	p.wg.Add(1)
	go func() {
		defer p.wg.Done()
		defer func() { <-p.sem }()

		if err := task(p.ctx); err != nil {
			if !errors.Is(err, context.Canceled) {
				p.mtx.Lock()
				p.errs = append(p.errs, err)
				p.mtx.Unlock()
			}
			if p.failFast {
				p.cancel(CancellationError("operation aborted"))
			}
			return
		}
		atomic.AddInt64(&p.succeeded, 1)
	}()
	return true
}

// cancelled reports whether the pool's context is already done (fail-fast
// triggered or the parent context cancelled). A feeder loop can poll this to
// stop producing/buffering work promptly, instead of only discovering the
// cancellation on its next submit.
func (p *workerPool) cancelled() bool {
	select {
	case <-p.ctx.Done():
		return true
	default:
		return false
	}
}

// wait blocks until all in-flight tasks finish, then releases the pool's
// context. It returns the number of succeeded tasks and the errors the
// workers collected.
func (p *workerPool) wait() (int, []error) {
	p.wg.Wait()
	p.cancel(nil)
	return int(p.succeeded), p.errs
}