Files
2026-08-11 09:00:01 +02:00

184 lines
4.0 KiB
Go

package job
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"sort"
"sync"
"time"
)
// Manager owns every job in the process.
type Manager struct {
mu sync.RWMutex
jobs map[string]*Job
// keep bounds how many finished jobs are retained.
keep int
}
// NewManager creates an empty job manager.
func NewManager() *Manager {
return &Manager{jobs: map[string]*Job{}, keep: 50}
}
// ErrNotFound is returned for an unknown job id.
var ErrNotFound = errors.New("job not found")
// Run creates a job and executes fn in the background. fn receives a context
// that is canceled when the job is canceled, and the job handle for progress
// reporting.
func (m *Manager) Run(parent context.Context, kind Kind, title string, dryRun bool, fn func(context.Context, *Job) error) *Job {
j := newJob(newID(), kind, title, dryRun)
ctx, cancel := context.WithCancel(parent)
j.cancel = cancel
m.mu.Lock()
m.jobs[j.snap.ID] = j
m.mu.Unlock()
m.prune()
go func() {
defer cancel()
j.start()
err := fn(ctx, j)
if err == nil && ctx.Err() != nil {
err = context.Canceled
}
if errors.Is(err, context.Canceled) {
err = context.Canceled
}
j.finish(err)
}()
return j
}
// Get returns a job by id.
func (m *Manager) Get(id string) (*Job, error) {
m.mu.RLock()
defer m.mu.RUnlock()
j, ok := m.jobs[id]
if !ok {
return nil, ErrNotFound
}
return j, nil
}
// List returns every job, newest first.
func (m *Manager) List() []Snapshot {
m.mu.RLock()
jobs := make([]*Job, 0, len(m.jobs))
for _, j := range m.jobs {
jobs = append(jobs, j)
}
m.mu.RUnlock()
out := make([]Snapshot, 0, len(jobs))
for _, j := range jobs {
s := j.Snapshot()
// The list view does not need the full log.
if len(s.Log) > 5 {
s.Log = s.Log[len(s.Log)-5:]
}
out = append(out, s)
}
sort.Slice(out, func(i, k int) bool { return out[i].CreatedAt.After(out[k].CreatedAt) })
return out
}
// Delete removes a finished job. A running job is canceled instead.
func (m *Manager) Delete(id string) error {
m.mu.Lock()
j, ok := m.jobs[id]
if !ok {
m.mu.Unlock()
return ErrNotFound
}
if !j.Snapshot().State.Terminal() {
m.mu.Unlock()
j.Cancel()
return nil
}
delete(m.jobs, id)
m.mu.Unlock()
return nil
}
// prune drops the oldest finished jobs once the retention limit is exceeded.
func (m *Manager) prune() {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.jobs) <= m.keep {
return
}
type entry struct {
id string
at time.Time
}
var finished []entry
for id, j := range m.jobs {
s := j.Snapshot()
if s.State.Terminal() {
finished = append(finished, entry{id, s.CreatedAt})
}
}
sort.Slice(finished, func(i, k int) bool { return finished[i].at.Before(finished[k].at) })
for i := 0; i < len(finished) && len(m.jobs) > m.keep; i++ {
delete(m.jobs, finished[i].id)
}
}
func newID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return hex.EncodeToString([]byte(time.Now().Format("150405.000000")))
}
return hex.EncodeToString(b)
}
// CountingReader wraps a reader and reports every read to a job step, which is
// how transfer progress reaches the UI.
type CountingReader struct {
R io.Reader
Job *Job
St *Step
pending int64
lastFlush time.Time
}
// NewCountingReader builds a progress-reporting reader.
func NewCountingReader(r io.Reader, j *Job, st *Step) *CountingReader {
return &CountingReader{R: r, Job: j, St: st, lastFlush: time.Now()}
}
// Read implements io.Reader, batching counter updates so a fast transfer does
// not flood subscribers with notifications.
func (c *CountingReader) Read(p []byte) (int, error) {
n, err := c.R.Read(p)
if n > 0 {
c.pending += int64(n)
if c.pending >= 4<<20 || time.Since(c.lastFlush) > 200*time.Millisecond {
c.flush()
}
}
if err != nil {
c.flush()
}
return n, err
}
// Flush pushes any buffered byte count to the job.
func (c *CountingReader) Flush() { c.flush() }
func (c *CountingReader) flush() {
if c.pending == 0 {
return
}
c.Job.AddBytes(c.St, c.pending)
c.pending = 0
c.lastFlush = time.Now()
}