Files
yatm/internal/executor/job.go
T
Samuel CuiandClaude Opus 5.5 afc7b87581 Keep Job settlement visible and retain independent copy failures
Preserve the existing Job lifecycle while keeping timing, cancellation and independent pipeline errors observable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:01:28 +08:00

495 lines
15 KiB
Go

package executor
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/samuelncui/yatm/entity"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
)
const (
localExecutorID = "local"
singletonJobID = 1
defaultJobPageSize = 20
maxJobPageSize = 200
)
// ModelJob is the opaque jobs-catalog model used by schema migration and inspection code.
var ModelJob = &jobCatalogRow{}
var ErrJobNotFound = errors.New("job not found")
// jobCatalogRow is the Executor catalog's query projection. Authoritative lifecycle state remains
// in JobRecord and is copied into a Job snapshot when the catalog row is read.
type jobCatalogRow struct {
ID int64 `gorm:"primaryKey;autoIncrement;index:idx_executor_id,priority:2"`
ExecutorID string `gorm:"type:varchar(128);not null;index:idx_executor_id,priority:1"`
CreatedAtNS int64 `gorm:"not null;autoCreateTime:nano" json:"created_at_ns,string"`
UpdatedAtNS int64 `gorm:"not null;autoUpdateTime:nano;index:idx_job_updated_at" json:"updated_at_ns,string"`
DeletedAtNS soft_delete.DeletedAt `gorm:"type:bigint;softDelete:nano,DeletedAtField:UpdatedAtNS,DeletedAtFieldUnit:nano;index:idx_job_deleted_at" json:"deleted_at_ns,string"`
Revision int64 `gorm:"not null;default:0;index:idx_job_revision"`
TargetName string
LocationID int64 `gorm:"index:idx_jobs_location"`
MediaID int64 `gorm:"index:idx_jobs_media"`
CatalogKind entity.JobKind `gorm:"not null;default:0;index:idx_jobs_kind"`
}
func (jobCatalogRow) TableName() string {
return "jobs"
}
func (row *jobCatalogRow) snapshot() *Job {
return &Job{
ID: row.ID,
CreatedAtNS: row.CreatedAtNS,
UpdatedAtNS: row.UpdatedAtNS,
DeletedAtNS: int64(row.DeletedAtNS),
Revision: row.Revision,
JobTarget: JobTarget{
TargetName: row.TargetName,
LocationID: row.LocationID,
MediaID: row.MediaID,
},
}
}
// Job is one runtime snapshot assembled from the Executor catalog, its durable JobRecord, and the
// attached runner's live phase. It is not a GORM model.
type Job struct {
ID int64
CreatedAtNS int64 `json:"created_at_ns,string"`
UpdatedAtNS int64 `json:"updated_at_ns,string"`
DeletedAtNS int64 `json:"deleted_at_ns,string"`
Revision int64
JobTarget
Error string
Kind entity.JobKind
Status entity.JobStatus
Priority int64
Phase entity.JobPhase
// Checkpoint retains the last published manifest position for runner reconstruction and result
// inspection. Admission uses Status; a complete manifest does not make a FAILED Job executable.
Checkpoint entity.JobStatus
}
// CheckpointState reports the last published manifest position for retained result inspection.
func (job *Job) CheckpointState() entity.JobStatus {
return manifestPosition(job.Checkpoint)
}
// manifestPosition reports the manifest position one durable state records. Only a state a runner
// publishes while its manifest exists is a position; a failure or a cancellation is not.
func manifestPosition(state entity.JobStatus) entity.JobStatus {
switch state {
case entity.JobStatus_JOB_STATUS_PREPARING, entity.JobStatus_JOB_STATUS_READY, entity.JobStatus_JOB_STATUS_COMPLETED:
return state
default:
return entity.JobStatus_JOB_STATUS_UNSPECIFIED
}
}
// JobTarget is immutable catalog navigation metadata, not execution state.
type JobTarget struct {
TargetName string
LocationID int64
MediaID int64
}
type JobRecord struct {
Error string
ID int64 `gorm:"primaryKey;autoIncrement:false;check:id = 1"`
Kind entity.JobKind `gorm:"not null"`
Status entity.JobStatus `gorm:"not null"`
Priority int64 `gorm:"not null"`
LatestAttemptStartedAtNS *int64 `json:"latest_attempt_started_at_ns,string,omitempty"`
LatestAttemptFinishedAtNS *int64 `json:"latest_attempt_finished_at_ns,string,omitempty"`
// Checkpoint retains the manifest position across a failed or stopped attempt.
Checkpoint entity.JobStatus
}
type JobPage struct {
Jobs []*Job
Revision int64
HasMore bool
}
// ToEntity converts the internal catalog projection to its public Job representation.
func (job *Job) ToEntity() *entity.Job {
value := &entity.Job{
Id: job.ID,
Kind: job.Kind,
Status: job.Status,
Priority: job.Priority,
CreatedAtNs: job.CreatedAtNS,
UpdatedAtNs: job.UpdatedAtNS,
DeletedAtNs: job.DeletedAtNS,
Phase: job.Phase,
Error: job.Error,
Revision: job.Revision,
}
if job.TargetName != "" {
value.TargetName = proto.String(job.TargetName)
}
if job.LocationID != 0 {
value.LocationId = proto.Int64(job.LocationID)
}
if job.MediaID != 0 {
value.MediaId = proto.Int64(job.MediaID)
}
return value
}
func (JobRecord) TableName() string {
return "job"
}
// DeleteJobs removes the selected Job bundles. A dry run validates the same selection and
// reports how many Jobs it would delete without removing a bundle or a catalog row.
func (e *Executor) DeleteJobs(ctx context.Context, dryRun bool, ids ...int64) (int64, error) {
var deleted int64
for _, id := range uniqueIDs(ids) {
if err := e.validateDeletable(ctx, id); err != nil {
return 0, err
}
if !dryRun {
if err := e.deleteJob(ctx, id); err != nil {
return 0, err
}
}
deleted++
}
return deleted, nil
}
// validateDeletable reports the running or missing conditions deletion rejects.
func (e *Executor) validateDeletable(ctx context.Context, id int64) error {
if e.IsRunning(id) {
return fmt.Errorf("delete running job failed, id=%d, %w", id, ErrJobBusy)
}
if _, err := e.GetJob(ctx, id); err != nil {
return err
}
return nil
}
func (e *Executor) deleteJob(ctx context.Context, id int64) error {
unlockJob := e.lockJob(id)
defer unlockJob()
if err := e.validateDeletable(ctx, id); err != nil {
return err
}
// Retain cached resources until their owner confirms cleanup succeeded.
e.runnersLock.Lock()
runner := e.runners[id]
e.runnersLock.Unlock()
if runner != nil {
if err := runner.Close(); err != nil {
return fmt.Errorf("close job runner failed, id=%d, %w", id, err)
}
e.removeRunner(id)
}
if err := os.RemoveAll(e.jobWorkPath(id)); err != nil {
return fmt.Errorf("remove job bundle failed, id=%d, %w", id, err)
}
// Publish the tombstone and its polling revision in one catalog transaction.
if err := e.withNextJobRevision(func(revision int64) (bool, error) {
err := e.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated := tx.Model(&jobCatalogRow{}).Where("id = ?", id).Update("revision", revision)
if updated.Error != nil {
return updated.Error
}
if updated.RowsAffected != 1 {
return ErrJobNotFound
}
deleted := tx.Delete(&jobCatalogRow{}, id)
if deleted.Error != nil {
return deleted.Error
}
if deleted.RowsAffected != 1 {
return ErrJobNotFound
}
return nil
})
return err == nil, err
}); err != nil {
return fmt.Errorf("delete job catalog failed, id=%d, %w", id, err)
}
return nil
}
func (e *Executor) MGetJob(ctx context.Context, ids ...int64) (map[int64]*Job, error) {
ids = uniqueIDs(ids)
if len(ids) == 0 {
return map[int64]*Job{}, nil
}
// Load catalog rows in one query, then hydrate each local Job DB.
rows := make([]*jobCatalogRow, 0, len(ids))
if err := e.readDB().WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, fmt.Errorf("query job catalog failed, %w", err)
}
result := make(map[int64]*Job, len(rows))
for _, row := range rows {
job := row.snapshot()
if err := e.hydrateJob(ctx, job); err != nil {
return nil, err
}
result[job.ID] = job
}
for _, id := range ids {
if result[id] == nil {
return nil, fmt.Errorf("get job failed, id=%d, %w", id, ErrJobNotFound)
}
}
return result, nil
}
func (e *Executor) GetJob(ctx context.Context, id int64) (*Job, error) {
jobs, err := e.MGetJob(ctx, id)
if err != nil {
return nil, err
}
return jobs[id], nil
}
func (e *Executor) ListJob(ctx context.Context, filter *entity.JobFilter) (*JobPage, error) {
// Select either incremental polling or one snapshot page.
if filter == nil {
filter = &entity.JobFilter{}
}
if (filter.LocationId != nil && *filter.LocationId <= 0) || (filter.MediaId != nil && *filter.MediaId <= 0) {
return nil, fmt.Errorf("Job target IDs must be positive")
}
if filter.Kind != nil {
if _, valid := entity.JobKind_name[int32(*filter.Kind)]; !valid || *filter.Kind == entity.JobKind_JOB_KIND_UNSPECIFIED {
return nil, fmt.Errorf("invalid Job kind filter")
}
}
if filter.ChangedAfterRevision != nil {
return e.listChangedJobs(ctx, filter)
}
if filter.BeforeId != nil && filter.Offset != nil {
return nil, fmt.Errorf("Job listing cannot combine before_id and offset")
}
// Freeze initial pagination at one valid catalog revision.
snapshot := e.currentJobRevision()
if filter.SnapshotRevision != nil {
snapshot = *filter.SnapshotRevision
if snapshot < 0 || snapshot > e.currentJobRevision() {
return nil, fmt.Errorf("Job listing snapshot revision is invalid, revision=%d", snapshot)
}
}
limit, err := jobPageSize(filter.Limit)
if err != nil {
return nil, err
}
// Read only one catalog page before hydrating the matching Job DBs.
query := e.readDB().WithContext(ctx).Where("revision > 0 AND revision <= ?", snapshot).Order("id DESC")
query = filterJobTarget(query, filter)
if filter.BeforeId != nil {
if *filter.BeforeId <= 0 {
return nil, fmt.Errorf("Job listing before_id must be positive")
}
query = query.Where("id < ?", *filter.BeforeId)
}
if filter.Offset != nil && *filter.Offset > 0 {
query = query.Offset(int(*filter.Offset))
}
var rows []*jobCatalogRow
if err := query.Limit(limit + 1).Find(&rows).Error; err != nil {
return nil, fmt.Errorf("list job catalog failed, %w", err)
}
hasMore := len(rows) > limit
if hasMore {
rows = rows[:limit]
}
jobs := jobSnapshots(rows)
if err := e.hydrateJobs(ctx, jobs); err != nil {
return nil, err
}
return &JobPage{Jobs: jobs, Revision: snapshot, HasMore: hasMore}, nil
}
func (e *Executor) listChangedJobs(ctx context.Context, filter *entity.JobFilter) (*JobPage, error) {
// Incremental polling returns tombstones and cannot apply state stored in a removed Job Bundle.
if filter.Offset != nil || filter.BeforeId != nil || filter.SnapshotRevision != nil {
return nil, fmt.Errorf("changed Job listing supports only a revision cursor and limit")
}
if *filter.ChangedAfterRevision < 0 {
return nil, fmt.Errorf("changed Job listing requires a non-negative revision")
}
limit, err := jobPageSize(filter.Limit)
if err != nil {
return nil, err
}
// Page through the unique revision order so concurrent same-millisecond changes remain distinct.
query := e.readDB().WithContext(ctx).Unscoped().
Where("revision > ?", *filter.ChangedAfterRevision).
Order("revision ASC, id ASC").Limit(limit + 1)
query = filterJobTarget(query, filter)
var rows []*jobCatalogRow
if err := query.Find(&rows).Error; err != nil {
return nil, fmt.Errorf("list changed Job catalog failed, %w", err)
}
hasMore := len(rows) > limit
if hasMore {
rows = rows[:limit]
}
jobs := jobSnapshots(rows)
// Deleted rows are complete tombstones; active rows still hydrate from their Job DB.
for _, job := range jobs {
if job.DeletedAtNS != 0 {
continue
}
if err := e.hydrateJob(ctx, job); err != nil {
return nil, err
}
}
revision := *filter.ChangedAfterRevision
if len(jobs) > 0 {
revision = jobs[len(jobs)-1].Revision
}
return &JobPage{Jobs: jobs, Revision: revision, HasMore: hasMore}, nil
}
func filterJobTarget(query *gorm.DB, filter *entity.JobFilter) *gorm.DB {
if filter.LocationId != nil {
query = query.Where("id IN (SELECT job_id FROM job_properties WHERE `key` = ? AND value = ?)", JobPropertyLocation, *filter.LocationId)
}
if filter.MediaId != nil {
query = query.Where("id IN (SELECT job_id FROM job_properties WHERE `key` = ? AND value = ?)", JobPropertyMedia, *filter.MediaId)
}
if filter.Kind != nil {
query = query.Where("catalog_kind = ?", *filter.Kind)
}
return query
}
func (e *Executor) hydrateJobs(ctx context.Context, jobs []*Job) error {
for _, job := range jobs {
if err := e.hydrateJob(ctx, job); err != nil {
return err
}
}
return nil
}
func jobSnapshots(rows []*jobCatalogRow) []*Job {
jobs := make([]*Job, 0, len(rows))
for _, row := range rows {
jobs = append(jobs, row.snapshot())
}
return jobs
}
func (e *Executor) hydrateJob(ctx context.Context, job *Job) error {
// An idle observation must precede its durable read, never follow a read of the old result.
job.Phase = e.jobPhase(job.ID)
// Read the settled record or a conservative snapshot of the still-active attempt.
db, closeDB, err := e.openStateDB(job.ID)
if err != nil {
return err
}
defer closeDB()
record := new(JobRecord)
if err := db.WithContext(ctx).First(record, singletonJobID).Error; err != nil {
return fmt.Errorf("read Job DB failed, id=%d, %w", job.ID, err)
}
job.Error = record.Error
job.Kind = record.Kind
job.Status = record.Status
job.Priority = record.Priority
job.Checkpoint = record.Checkpoint
return nil
}
// jobPhase retains the attempt's last working stage until its complete settlement is published.
func (e *Executor) jobPhase(jobID int64) entity.JobPhase {
e.attemptsLock.Lock()
defer e.attemptsLock.Unlock()
if current := e.attempts[jobID]; current != nil {
return current.phase
}
return entity.JobPhase_JOB_PHASE_UNSPECIFIED
}
func (e *Executor) EnsureJobWorkPath(_ context.Context, jobID int64) (string, error) {
workPath := e.jobWorkPath(jobID)
if err := os.MkdirAll(workPath, 0o755); err != nil {
return "", fmt.Errorf("create job directory failed, path=%q, %w", workPath, err)
}
return workPath, nil
}
func (e *Executor) jobWorkPath(jobID int64) string {
return filepath.Join(e.paths.Work, "jobs", strconv.FormatInt(jobID, 10))
}
func uniqueIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
result := make([]int64, 0, len(ids))
for _, id := range ids {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func jobPageSize(limit *int64) (int, error) {
if limit == nil {
return defaultJobPageSize, nil
}
if *limit <= 0 || *limit > maxJobPageSize {
return 0, fmt.Errorf("Job page limit must be between 1 and %d, limit=%d", maxJobPageSize, *limit)
}
return int(*limit), nil
}
func (e *Executor) cleanupFailedJob(ctx context.Context, jobID int64) error {
// Keep the runner reachable until it has released every file owned by failed creation.
ctx = context.WithoutCancel(ctx)
e.runnersLock.Lock()
runner := e.runners[jobID]
e.runnersLock.Unlock()
if runner != nil {
if err := runner.Close(); err != nil {
return fmt.Errorf("close failed Job runner, id=%d, %w", jobID, err)
}
e.removeRunner(jobID)
}
// Attempt every removal so one failure does not strand the other owned records; report each failure.
var result error
if err := os.RemoveAll(e.jobWorkPath(jobID)); err != nil {
result = errors.Join(result, fmt.Errorf("remove failed Job bundle, id=%d, %w", jobID, err))
}
if err := e.db.WithContext(ctx).Where("job_id = ?", jobID).Delete(&JobProperty{}).Error; err != nil {
result = errors.Join(result, fmt.Errorf("remove failed Job properties, id=%d, %w", jobID, err))
}
if err := e.db.WithContext(ctx).Unscoped().Delete(&jobCatalogRow{}, jobID).Error; err != nil {
result = errors.Join(result, fmt.Errorf("remove failed Job catalog entry, id=%d, %w", jobID, err))
}
return result
}