mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 12:26:33 +00:00
571 lines
15 KiB
Go
571 lines
15 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"sync"
|
|
|
|
mapset "github.com/deckarep/golang-set/v2"
|
|
"github.com/samuelncui/yatm/entity"
|
|
"github.com/samuelncui/yatm/internal/dataformat"
|
|
"github.com/samuelncui/yatm/library"
|
|
"github.com/samuelncui/yatm/tools"
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
ErrJobBusy = errors.New("job is running")
|
|
ErrJobNotRunning = errors.New("job is not running")
|
|
)
|
|
|
|
type attempt struct {
|
|
cancel context.CancelFunc
|
|
resources []attemptResource
|
|
}
|
|
|
|
type attemptResource struct {
|
|
key string
|
|
device string
|
|
releaseDevice bool
|
|
}
|
|
|
|
type jobMutex struct {
|
|
lock sync.Mutex
|
|
refs int
|
|
}
|
|
|
|
// Previewer is the Preview behavior consumed by Job runners and public APIs.
|
|
type Previewer interface {
|
|
Supports(sourcePath string) bool
|
|
Generate(ctx context.Context, sourcePath string, sha256 []byte, size, mtimeNS int64, force bool) ([]byte, error)
|
|
Manifest(signature []byte) (*entity.PreviewManifest, error)
|
|
Open(signature []byte, role string) (io.ReadCloser, error)
|
|
}
|
|
|
|
type Executor struct {
|
|
db *gorm.DB
|
|
lib *library.Library
|
|
|
|
devicesLock sync.Mutex
|
|
devices []string
|
|
availableDevices mapset.Set[string]
|
|
resourcesLock sync.Mutex
|
|
leasedResources mapset.Set[string]
|
|
|
|
runnersLock sync.Mutex
|
|
runners map[int64]Runner
|
|
|
|
jobLocksLock sync.Mutex
|
|
jobLocks map[int64]*jobMutex
|
|
|
|
attemptsLock sync.Mutex
|
|
attempts map[int64]*attempt
|
|
quiescing bool
|
|
|
|
jobRevisionLock sync.Mutex
|
|
jobRevision int64
|
|
|
|
paths Paths
|
|
scripts Scripts
|
|
previews Previewer
|
|
onlineRuntimePaths []string
|
|
onlineRuntimePathProvider func() []string
|
|
temporaryNamesLock sync.RWMutex
|
|
temporaryNames map[string]struct{}
|
|
}
|
|
|
|
type Paths struct {
|
|
Work string `yaml:"work"`
|
|
Access []AccessRange `yaml:"access"`
|
|
Source string `yaml:"source,omitempty"`
|
|
Target string `yaml:"target,omitempty"`
|
|
Volumes []string `yaml:"volumes"`
|
|
}
|
|
|
|
type AccessRange struct {
|
|
Root string `yaml:"root"`
|
|
Ignore string `yaml:"ignore"`
|
|
}
|
|
|
|
type Scripts struct {
|
|
Encrypt string `yaml:"encrypt"`
|
|
Mkfs string `yaml:"mkfs"`
|
|
Mount string `yaml:"mount"`
|
|
Umount string `yaml:"umount"`
|
|
ReadInfo string `yaml:"read_info"`
|
|
}
|
|
|
|
func New(
|
|
db *gorm.DB, lib *library.Library,
|
|
devices []string, paths Paths, scripts Scripts, previews Previewer,
|
|
) *Executor {
|
|
return &Executor{
|
|
db: db,
|
|
lib: lib,
|
|
devices: devices,
|
|
availableDevices: mapset.NewThreadUnsafeSet(devices...),
|
|
leasedResources: mapset.NewThreadUnsafeSet[string](),
|
|
runners: make(map[int64]Runner),
|
|
jobLocks: make(map[int64]*jobMutex),
|
|
attempts: make(map[int64]*attempt),
|
|
paths: paths,
|
|
scripts: scripts,
|
|
previews: previews,
|
|
}
|
|
}
|
|
|
|
func (e *Executor) AutoMigrate() error {
|
|
// Format validation precedes schema or query-projection changes.
|
|
if err := dataformat.InitializeCatalog(e.db); err != nil {
|
|
return err
|
|
}
|
|
if err := dataformat.CheckBundles(e.db, e.paths.Work); err != nil {
|
|
return err
|
|
}
|
|
if err := e.db.AutoMigrate(ModelJob, &JobResource{}); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Continue the published revision sequence; migration already assigns initial revisions.
|
|
var revision int64
|
|
if err := e.db.Unscoped().Model(&Job{}).Select("COALESCE(MAX(revision), 0)").Scan(&revision).Error; err != nil {
|
|
return fmt.Errorf("read current Job revision failed, %w", err)
|
|
}
|
|
e.jobRevision = revision
|
|
return e.refreshJobProjections(context.Background())
|
|
}
|
|
|
|
// CreateJob persists a complete typed Job Bundle before starting asynchronous indexing.
|
|
func (e *Executor) CreateJob(
|
|
ctx context.Context,
|
|
kind entity.JobKind,
|
|
priority int64,
|
|
initialize func(*gorm.DB) error,
|
|
) (*Job, error) {
|
|
return e.CreatePreparedJob(ctx, kind, priority, initialize, nil)
|
|
}
|
|
|
|
// CreatePreparedJob transfers operation admission to a complete runner before indexing can start.
|
|
func (e *Executor) CreatePreparedJob(
|
|
ctx context.Context,
|
|
kind entity.JobKind,
|
|
priority int64,
|
|
initialize func(*gorm.DB) error,
|
|
prepare func(Runner) error,
|
|
) (_ *Job, rerr error) {
|
|
return e.createPreparedJob(ctx, kind, priority, JobTarget{}, initialize, prepare)
|
|
}
|
|
|
|
// CreateTargetJob captures the immutable navigation target of a resource-scoped Job.
|
|
func (e *Executor) CreateTargetJob(ctx context.Context, kind entity.JobKind, priority int64, target JobTarget, initialize func(*gorm.DB) error, prepare func(Runner) error) (*Job, error) {
|
|
// Scan may select a Location, a Media, or multiple resources through its frozen selections.
|
|
if kind != entity.JobKind_SCAN || target.LocationID < 0 || target.MediaID < 0 || target.LocationID > 0 && target.MediaID > 0 {
|
|
return nil, fmt.Errorf("Job target does not match its kind")
|
|
}
|
|
return e.createPreparedJob(ctx, kind, priority, target, initialize, prepare)
|
|
}
|
|
|
|
func (e *Executor) createPreparedJob(ctx context.Context, kind entity.JobKind, priority int64, target JobTarget, initialize func(*gorm.DB) error, prepare func(Runner) error) (_ *Job, rerr error) {
|
|
// Validate the type before creating any durable data.
|
|
if initialize == nil {
|
|
return nil, fmt.Errorf("create job failed, initializer is nil")
|
|
}
|
|
if _, err := getRunnerFactory(kind); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Allocate the catalog ID before creating the bundle that uses it as its path.
|
|
job := &Job{Priority: priority, JobTarget: target, CatalogKind: kind, CatalogStatus: entity.JobStatus_INDEXING}
|
|
job.ExecutorID = localExecutorID
|
|
if err := e.db.WithContext(ctx).Create(job).Error; err != nil {
|
|
return nil, fmt.Errorf("create job catalog failed, %w", err)
|
|
}
|
|
unlockJob := e.lockJob(job.ID)
|
|
defer unlockJob()
|
|
defer func() {
|
|
if rerr == nil {
|
|
return
|
|
}
|
|
e.cleanupFailedJob(ctx, job.ID)
|
|
}()
|
|
|
|
// Create the common Job DB before the kind-specific manifest.
|
|
if err := e.createBundle(ctx, job, &JobRecord{
|
|
ID: singletonJobID,
|
|
Kind: kind,
|
|
Status: entity.JobStatus_INDEXING,
|
|
Priority: job.Priority,
|
|
}, initialize); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the kind-specific schema before returning the RPC response.
|
|
runner, err := e.GetJobRunner(ctx, job.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created, err := e.GetJob(ctx, job.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if prepare != nil {
|
|
if err := prepare(runner); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := e.startIndex(job.ID, runner); err != nil {
|
|
return nil, err
|
|
}
|
|
created.Phase = entity.JobPhase_JOB_PHASE_INDEXING
|
|
e.TouchJob(job.ID)
|
|
return created, nil
|
|
}
|
|
|
|
func (e *Executor) RetryIndex(ctx context.Context, jobID int64) error {
|
|
unlockJob := e.lockJob(jobID)
|
|
defer unlockJob()
|
|
|
|
job, err := e.GetJob(ctx, jobID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if job.Status != entity.JobStatus_INDEXING {
|
|
return fmt.Errorf("job cannot index from status %s", job.Status)
|
|
}
|
|
runner, err := e.GetJobRunner(ctx, jobID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.startIndex(jobID, runner)
|
|
}
|
|
|
|
func (e *Executor) startIndex(jobID int64, runner Runner) error {
|
|
indexCtx, cancel := context.WithCancel(tools.ShutdownContext)
|
|
if !e.beginAttempt(jobID, cancel) {
|
|
cancel()
|
|
return fmt.Errorf("index job failed, id=%d, %w", jobID, ErrJobBusy)
|
|
}
|
|
|
|
tools.Working()
|
|
go func() {
|
|
defer tools.Done()
|
|
defer e.endAttempt(jobID)
|
|
defer cancel()
|
|
|
|
if err := runner.Index(indexCtx); err != nil {
|
|
runner.Logger().WithContext(indexCtx).WithError(err).Errorf("job indexing failed, id=%d", jobID)
|
|
return
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// UpdateJobStatus advances the common durable checkpoint for a Job runner.
|
|
func (e *Executor) UpdateJobStatus(
|
|
ctx context.Context, jobID int64, from, to entity.JobStatus,
|
|
) error {
|
|
db, closeDB, err := e.openStateDB(jobID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDB()
|
|
|
|
result := db.WithContext(ctx).Model(&JobRecord{}).
|
|
Where("id = ? AND status = ?", singletonJobID, from).
|
|
Update("status", to)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("update Job status failed, id=%d, %w", jobID, result.Error)
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return fmt.Errorf("update Job status failed, id=%d status=%s", jobID, from)
|
|
}
|
|
e.TouchJob(jobID)
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) GetJobRunner(ctx context.Context, id int64) (Runner, error) {
|
|
e.runnersLock.Lock()
|
|
runner := e.runners[id]
|
|
e.runnersLock.Unlock()
|
|
if runner != nil {
|
|
return runner, nil
|
|
}
|
|
|
|
// Build the type-specific Job object without holding the cache lock.
|
|
job, err := e.GetJob(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
factory, err := getRunnerFactory(job.Kind)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runner, err = factory(ctx, e, job)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create job runner failed, id=%d kind=%s, %w", id, job.Kind, err)
|
|
}
|
|
|
|
// Publish exactly one cached object even if a caller bypasses the Job lock.
|
|
e.runnersLock.Lock()
|
|
if existing := e.runners[id]; existing != nil {
|
|
e.runnersLock.Unlock()
|
|
_ = runner.Close()
|
|
return existing, nil
|
|
}
|
|
e.runners[id] = runner
|
|
e.runnersLock.Unlock()
|
|
return runner, nil
|
|
}
|
|
|
|
// StartJob starts one asynchronous typed attempt through the common lifecycle.
|
|
func (e *Executor) StartJob(
|
|
ctx context.Context,
|
|
jobID int64,
|
|
kind entity.JobKind,
|
|
run func(context.Context, Runner) error,
|
|
) error {
|
|
unlockJob := e.lockJob(jobID)
|
|
defer unlockJob()
|
|
|
|
// Reject invalid or terminal work before allocating an attempt.
|
|
if run == nil {
|
|
return fmt.Errorf("start job failed, runner function is nil")
|
|
}
|
|
job, err := e.GetJob(ctx, jobID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if job.Status != entity.JobStatus_PENDING {
|
|
return fmt.Errorf("job cannot start from status %s", job.Status)
|
|
}
|
|
if job.Kind != kind {
|
|
return fmt.Errorf("unexpected job kind, id=%d got=%s want=%s", jobID, job.Kind, kind)
|
|
}
|
|
runner, err := e.GetJobRunner(ctx, jobID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Register the attempt once so duplicate Start calls fail deterministically.
|
|
runCtx, cancel := context.WithCancel(tools.ShutdownContext)
|
|
if !e.beginAttempt(jobID, cancel) {
|
|
cancel()
|
|
return fmt.Errorf("start job failed, id=%d, %w", jobID, ErrJobBusy)
|
|
}
|
|
|
|
// Run asynchronously while keeping shutdown and cancellation observable.
|
|
tools.Working()
|
|
go func() {
|
|
defer tools.Done()
|
|
defer e.endAttempt(jobID)
|
|
defer cancel()
|
|
|
|
if err := run(runCtx, runner); err != nil {
|
|
runner.Logger().WithContext(runCtx).WithError(err).Errorf("job attempt failed, id=%d", jobID)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) Cancel(jobID int64) error {
|
|
e.attemptsLock.Lock()
|
|
attempt := e.attempts[jobID]
|
|
e.attemptsLock.Unlock()
|
|
|
|
if attempt == nil {
|
|
return fmt.Errorf("cancel job failed, id=%d, %w", jobID, ErrJobNotRunning)
|
|
}
|
|
attempt.cancel()
|
|
return nil
|
|
}
|
|
|
|
// UseJobRunner executes a typed read while excluding concurrent Job deletion.
|
|
func (e *Executor) UseJobRunner(
|
|
ctx context.Context,
|
|
jobID int64,
|
|
kind entity.JobKind,
|
|
use func(Runner) error,
|
|
) error {
|
|
unlockJob := e.lockJob(jobID)
|
|
defer unlockJob()
|
|
|
|
// Resolve only the expected typed runner while deletion remains excluded.
|
|
if use == nil {
|
|
return fmt.Errorf("use job runner failed, function is nil")
|
|
}
|
|
job, err := e.GetJob(ctx, jobID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if job.Kind != kind {
|
|
return fmt.Errorf("unexpected job kind, id=%d got=%s want=%s", jobID, job.Kind, kind)
|
|
}
|
|
runner, err := e.GetJobRunner(ctx, jobID)
|
|
if err != nil {
|
|
return fmt.Errorf("get job runner failed, id=%d, %w", jobID, err)
|
|
}
|
|
if err := use(runner); err != nil {
|
|
return fmt.Errorf("use job runner failed, id=%d, %w", jobID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) lockJob(jobID int64) func() {
|
|
e.jobLocksLock.Lock()
|
|
mutex := e.jobLocks[jobID]
|
|
if mutex == nil {
|
|
mutex = new(jobMutex)
|
|
e.jobLocks[jobID] = mutex
|
|
}
|
|
mutex.refs++
|
|
e.jobLocksLock.Unlock()
|
|
|
|
mutex.lock.Lock()
|
|
return func() {
|
|
mutex.lock.Unlock()
|
|
e.jobLocksLock.Lock()
|
|
mutex.refs--
|
|
if mutex.refs == 0 {
|
|
delete(e.jobLocks, jobID)
|
|
}
|
|
e.jobLocksLock.Unlock()
|
|
}
|
|
}
|
|
|
|
func (e *Executor) IsRunning(jobID int64) bool {
|
|
e.attemptsLock.Lock()
|
|
defer e.attemptsLock.Unlock()
|
|
return e.attempts[jobID] != nil
|
|
}
|
|
|
|
// RunningJobIDs returns a stable snapshot of active Job attempts.
|
|
func (e *Executor) RunningJobIDs() []int64 {
|
|
e.attemptsLock.Lock()
|
|
ids := e.runningJobIDsLocked()
|
|
e.attemptsLock.Unlock()
|
|
|
|
return ids
|
|
}
|
|
|
|
// TryQuiesce rejects new attempts after confirming that current operations have drained.
|
|
func (e *Executor) TryQuiesce() ([]int64, bool) {
|
|
e.attemptsLock.Lock()
|
|
defer e.attemptsLock.Unlock()
|
|
|
|
ids := e.runningJobIDsLocked()
|
|
if len(ids) > 0 {
|
|
return ids, false
|
|
}
|
|
e.quiescing = true
|
|
return nil, true
|
|
}
|
|
|
|
func (e *Executor) runningJobIDsLocked() []int64 {
|
|
ids := make([]int64, 0, len(e.attempts))
|
|
for id := range e.attempts {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
return ids
|
|
}
|
|
|
|
func (e *Executor) beginAttempt(jobID int64, cancel context.CancelFunc) bool {
|
|
e.attemptsLock.Lock()
|
|
defer e.attemptsLock.Unlock()
|
|
|
|
if e.quiescing || e.attempts[jobID] != nil {
|
|
return false
|
|
}
|
|
e.attempts[jobID] = &attempt{cancel: cancel}
|
|
return true
|
|
}
|
|
|
|
func (e *Executor) endAttempt(jobID int64) {
|
|
// Detach the complete attempt before releasing any of its resources.
|
|
e.attemptsLock.Lock()
|
|
current := e.attempts[jobID]
|
|
delete(e.attempts, jobID)
|
|
e.attemptsLock.Unlock()
|
|
|
|
if current == nil {
|
|
return
|
|
}
|
|
|
|
// Release logical resource keys for future attempts.
|
|
e.resourcesLock.Lock()
|
|
for _, resource := range current.resources {
|
|
e.leasedResources.Remove(resource.key)
|
|
}
|
|
e.resourcesLock.Unlock()
|
|
|
|
// Return only devices whose physical cleanup completed successfully.
|
|
e.devicesLock.Lock()
|
|
for _, resource := range current.resources {
|
|
if resource.releaseDevice {
|
|
e.availableDevices.Add(resource.device)
|
|
}
|
|
}
|
|
e.devicesLock.Unlock()
|
|
}
|
|
|
|
func (e *Executor) withNextJobRevision(update func(int64) (bool, error)) error {
|
|
e.jobRevisionLock.Lock()
|
|
defer e.jobRevisionLock.Unlock()
|
|
|
|
revision := e.jobRevision + 1
|
|
changed, err := update(revision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Never expose a polling cursor that has no persisted catalog observation.
|
|
if changed {
|
|
e.jobRevision = revision
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) currentJobRevision() int64 {
|
|
e.jobRevisionLock.Lock()
|
|
defer e.jobRevisionLock.Unlock()
|
|
return e.jobRevision
|
|
}
|
|
|
|
// TouchJob publishes a runtime phase change through the catalog revision.
|
|
func (e *Executor) TouchJob(jobID int64) {
|
|
// Reconcile the query projection from the authoritative bundle on every publication.
|
|
err := e.publishJobProjection(context.Background(), jobID)
|
|
if err != nil {
|
|
logrus.WithError(err).Errorf("touch Job catalog failed, id=%d", jobID)
|
|
}
|
|
}
|
|
|
|
func (e *Executor) removeRunner(jobID int64) Runner {
|
|
e.runnersLock.Lock()
|
|
defer e.runnersLock.Unlock()
|
|
|
|
runner := e.runners[jobID]
|
|
delete(e.runners, jobID)
|
|
return runner
|
|
}
|
|
|
|
func (e *Executor) Lib() *library.Library {
|
|
return e.lib
|
|
}
|
|
|
|
func (e *Executor) Paths() Paths {
|
|
return e.paths
|
|
}
|
|
|
|
func (e *Executor) Scripts() Scripts {
|
|
return e.scripts
|
|
}
|
|
|
|
func (e *Executor) Previews() Previewer {
|
|
return e.previews
|
|
}
|