package services import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "regexp" "strconv" "strings" "time" "stream.api/internal/database/model" "stream.api/internal/database/query" "stream.api/internal/video/runtime/domain" ) type JobQueue interface { Enqueue(ctx context.Context, job *domain.Job) error Dequeue(ctx context.Context) (*domain.Job, error) } type LogPubSub interface { Publish(ctx context.Context, jobID string, logLine string, progress float64) error PublishResource(ctx context.Context, agentID string, data []byte) error PublishCancel(ctx context.Context, agentID string, jobID string) error PublishJobUpdate(ctx context.Context, jobID string, status string, videoID string) error Subscribe(ctx context.Context, jobID string) (<-chan domain.LogEntry, error) SubscribeResources(ctx context.Context) (<-chan domain.SystemResource, error) SubscribeCancel(ctx context.Context, agentID string) (<-chan string, error) SubscribeJobUpdates(ctx context.Context) (<-chan string, error) } type JobService struct { queue JobQueue pubsub LogPubSub } func NewJobService(queue JobQueue, pubsub LogPubSub) *JobService { return &JobService{queue: queue, pubsub: pubsub} } var ErrInvalidJobCursor = errors.New("invalid job cursor") const ( defaultJobPageSize = 20 maxJobPageSize = 100 jobCursorVersion = 1 ) type PaginatedJobs struct { Jobs []*domain.Job `json:"jobs"` Total int64 `json:"total"` Offset int `json:"offset"` Limit int `json:"limit"` HasMore bool `json:"has_more"` NextCursor string `json:"next_cursor,omitempty"` PageSize int `json:"page_size"` } type jobListCursor struct { Version int `json:"v"` CreatedAtUnixNano int64 `json:"created_at_unix_nano"` ID string `json:"id"` AgentID string `json:"agent_id,omitempty"` } type jobConfigEnvelope struct { Image string `json:"image,omitempty"` Commands []string `json:"commands,omitempty"` Environment map[string]string `json:"environment,omitempty"` Name string `json:"name,omitempty"` UserID string `json:"user_id,omitempty"` VideoID string `json:"video_id,omitempty"` TimeLimit int64 `json:"time_limit,omitempty"` } func strPtr(v string) *string { return &v } func int64Ptr(v int64) *int64 { return &v } func boolPtr(v bool) *bool { return &v } func float64Ptr(v float64) *float64 { return &v } func timePtr(v time.Time) *time.Time { return &v } func parseJobConfig(raw *string) jobConfigEnvelope { if raw == nil || strings.TrimSpace(*raw) == "" { return jobConfigEnvelope{} } var cfg jobConfigEnvelope _ = json.Unmarshal([]byte(*raw), &cfg) return cfg } func encodeJobConfig(raw []byte, name, userID, videoID string, timeLimit int64) string { cfg := parseJobConfig(strPtr(string(raw))) if name != "" { cfg.Name = name } if userID != "" { cfg.UserID = userID } if videoID != "" { cfg.VideoID = videoID } if timeLimit > 0 { cfg.TimeLimit = timeLimit } encoded, _ := json.Marshal(cfg) return string(encoded) } func normalizeJobPageSize(pageSize int) int { if pageSize <= 0 { return defaultJobPageSize } if pageSize > maxJobPageSize { return maxJobPageSize } return pageSize } func encodeJobListCursor(cursor jobListCursor) (string, error) { payload, err := json.Marshal(cursor) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(payload), nil } func decodeJobListCursor(raw string) (*jobListCursor, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } payload, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { return nil, ErrInvalidJobCursor } var cursor jobListCursor if err := json.Unmarshal(payload, &cursor); err != nil { return nil, ErrInvalidJobCursor } if cursor.Version != jobCursorVersion || cursor.CreatedAtUnixNano <= 0 || strings.TrimSpace(cursor.ID) == "" { return nil, ErrInvalidJobCursor } cursor.ID = strings.TrimSpace(cursor.ID) cursor.AgentID = strings.TrimSpace(cursor.AgentID) return &cursor, nil } func buildJobListCursor(job *model.Job, agentID string) (string, error) { if job == nil || job.CreatedAt == nil { return "", nil } return encodeJobListCursor(jobListCursor{ Version: jobCursorVersion, CreatedAtUnixNano: job.CreatedAt.UTC().UnixNano(), ID: strings.TrimSpace(job.ID), AgentID: strings.TrimSpace(agentID), }) } func listJobsByOffset(ctx context.Context, agentID string, offset, limit int) (*PaginatedJobs, error) { if offset < 0 { offset = 0 } limit = normalizeJobPageSize(limit) q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) if agentID != "" { agentNumeric, err := strconv.ParseInt(agentID, 10, 64) if err != nil { return &PaginatedJobs{Jobs: []*domain.Job{}, Total: 0, Offset: offset, Limit: limit, PageSize: limit, HasMore: false}, nil } q = q.Where(query.Job.AgentID.Eq(agentNumeric)) } jobs, total, err := q.FindByPage(offset, limit) if err != nil { return nil, err } items := make([]*domain.Job, 0, len(jobs)) for _, job := range jobs { items = append(items, toDomainJob(job)) } return &PaginatedJobs{Jobs: items, Total: total, Offset: offset, Limit: limit, PageSize: limit, HasMore: offset+len(items) < int(total)}, nil } func toDomainJob(job *model.Job) *domain.Job { if job == nil { return nil } cfg := parseJobConfig(job.Config) result := &domain.Job{ID: job.ID, Name: cfg.Name, UserID: cfg.UserID, VideoID: cfg.VideoID, TimeLimit: cfg.TimeLimit} if job.Status != nil { result.Status = domain.JobStatus(*job.Status) } if job.Priority != nil { result.Priority = int(*job.Priority) } if job.InputURL != nil { result.InputURL = *job.InputURL } if job.OutputURL != nil { result.OutputURL = *job.OutputURL } if job.TotalDuration != nil { result.TotalDuration = *job.TotalDuration } if job.CurrentTime != nil { result.CurrentTime = *job.CurrentTime } if job.Progress != nil { result.Progress = *job.Progress } if job.AgentID != nil { agentID := strconv.FormatInt(*job.AgentID, 10) result.AgentID = &agentID } if job.Logs != nil { result.Logs = *job.Logs } if job.Config != nil { result.Config = *job.Config } if job.Cancelled != nil { result.Cancelled = *job.Cancelled } if job.RetryCount != nil { result.RetryCount = int(*job.RetryCount) } if job.MaxRetries != nil { result.MaxRetries = int(*job.MaxRetries) } if job.CreatedAt != nil { result.CreatedAt = *job.CreatedAt } if job.UpdatedAt != nil { result.UpdatedAt = *job.UpdatedAt } return result } func (s *JobService) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*domain.Job, error) { status := string(domain.JobStatusPending) now := time.Now() job := &model.Job{ ID: fmt.Sprintf("job-%d", now.UnixNano()), Status: strPtr(status), Priority: int64Ptr(int64(priority)), Config: strPtr(encodeJobConfig(config, name, userID, videoID, timeLimit)), Cancelled: boolPtr(false), RetryCount: int64Ptr(0), MaxRetries: int64Ptr(3), CreatedAt: timePtr(now), UpdatedAt: timePtr(now), } if err := query.Job.WithContext(ctx).Create(job); err != nil { return nil, err } if err := syncVideoStatus(ctx, videoID, domain.JobStatusPending); err != nil { return nil, err } domainJob := toDomainJob(job) if err := s.queue.Enqueue(ctx, domainJob); err != nil { return nil, err } _ = s.pubsub.PublishJobUpdate(ctx, job.ID, status, videoID) return domainJob, nil } func (s *JobService) ListJobs(ctx context.Context, offset, limit int) (*PaginatedJobs, error) { return listJobsByOffset(ctx, "", offset, limit) } func (s *JobService) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*PaginatedJobs, error) { return listJobsByOffset(ctx, strings.TrimSpace(agentID), offset, limit) } func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*PaginatedJobs, error) { agentID = strings.TrimSpace(agentID) pageSize = normalizeJobPageSize(pageSize) decodedCursor, err := decodeJobListCursor(cursor) if err != nil { return nil, err } if decodedCursor != nil && decodedCursor.AgentID != agentID { return nil, ErrInvalidJobCursor } q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) if agentID != "" { agentNumeric, err := strconv.ParseInt(agentID, 10, 64) if err != nil { return &PaginatedJobs{Jobs: []*domain.Job{}, Total: 0, Limit: pageSize, PageSize: pageSize, HasMore: false}, nil } q = q.Where(query.Job.AgentID.Eq(agentNumeric)) } var cursorTime time.Time if decodedCursor != nil { cursorTime = time.Unix(0, decodedCursor.CreatedAtUnixNano).UTC() } queryDB := q.UnderlyingDB() if decodedCursor != nil { queryDB = queryDB.Where("(created_at < ?) OR (created_at = ? AND id < ?)", cursorTime, cursorTime, decodedCursor.ID) } var jobs []*model.Job if err := queryDB.Limit(pageSize + 1).Find(&jobs).Error; err != nil { return nil, err } hasMore := len(jobs) > pageSize if hasMore { jobs = jobs[:pageSize] } items := make([]*domain.Job, 0, len(jobs)) for _, job := range jobs { items = append(items, toDomainJob(job)) } nextCursor := "" if hasMore && len(jobs) > 0 { nextCursor, err = buildJobListCursor(jobs[len(jobs)-1], agentID) if err != nil { return nil, err } } return &PaginatedJobs{ Jobs: items, Total: 0, Limit: pageSize, PageSize: pageSize, HasMore: hasMore, NextCursor: nextCursor, }, nil } func (s *JobService) GetJob(ctx context.Context, id string) (*domain.Job, error) { job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(id)).First() if err != nil { return nil, err } return toDomainJob(job), nil } func (s *JobService) GetNextJob(ctx context.Context) (*domain.Job, error) { return s.queue.Dequeue(ctx) } func (s *JobService) SubscribeSystemResources(ctx context.Context) (<-chan domain.SystemResource, error) { return s.pubsub.SubscribeResources(ctx) } func (s *JobService) SubscribeJobLogs(ctx context.Context, jobID string) (<-chan domain.LogEntry, error) { return s.pubsub.Subscribe(ctx, jobID) } func (s *JobService) SubscribeCancel(ctx context.Context, agentID string) (<-chan string, error) { return s.pubsub.SubscribeCancel(ctx, agentID) } func (s *JobService) SubscribeJobUpdates(ctx context.Context) (<-chan string, error) { return s.pubsub.SubscribeJobUpdates(ctx) } func (s *JobService) UpdateJobStatus(ctx context.Context, jobID string, status domain.JobStatus) error { job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() if err != nil { return err } now := time.Now() job.Status = strPtr(string(status)) job.UpdatedAt = &now if err := query.Job.WithContext(ctx).Save(job); err != nil { return err } cfg := parseJobConfig(job.Config) if err := syncVideoStatus(ctx, cfg.VideoID, status); err != nil { return err } return s.pubsub.PublishJobUpdate(ctx, jobID, string(status), cfg.VideoID) } func (s *JobService) AssignJob(ctx context.Context, jobID string, agentID string) error { job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() if err != nil { return err } agentNumeric, err := strconv.ParseInt(agentID, 10, 64) if err != nil { return err } now := time.Now() status := string(domain.JobStatusRunning) job.AgentID = &agentNumeric job.Status = &status job.UpdatedAt = &now if err := query.Job.WithContext(ctx).Save(job); err != nil { return err } cfg := parseJobConfig(job.Config) if err := syncVideoStatus(ctx, cfg.VideoID, domain.JobStatusRunning); err != nil { return err } return s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID) } func (s *JobService) CancelJob(ctx context.Context, jobID string) error { job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() if err != nil { return fmt.Errorf("job not found: %w", err) } currentStatus := "" if job.Status != nil { currentStatus = *job.Status } if currentStatus != string(domain.JobStatusPending) && currentStatus != string(domain.JobStatusRunning) { return fmt.Errorf("cannot cancel job with status %s", currentStatus) } cancelled := true status := string(domain.JobStatusCancelled) now := time.Now() job.Cancelled = &cancelled job.Status = &status job.UpdatedAt = &now if err := query.Job.WithContext(ctx).Save(job); err != nil { return err } cfg := parseJobConfig(job.Config) if err := syncVideoStatus(ctx, cfg.VideoID, domain.JobStatusCancelled); err != nil { return err } _ = s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID) if job.AgentID != nil { _ = s.pubsub.PublishCancel(ctx, strconv.FormatInt(*job.AgentID, 10), job.ID) } return s.pubsub.Publish(ctx, jobID, "[SYSTEM] Job cancelled by admin", -1) } func (s *JobService) RetryJob(ctx context.Context, jobID string) (*domain.Job, error) { job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() if err != nil { return nil, fmt.Errorf("job not found: %w", err) } currentStatus := "" if job.Status != nil { currentStatus = *job.Status } if currentStatus != string(domain.JobStatusFailure) && currentStatus != string(domain.JobStatusCancelled) { return nil, fmt.Errorf("cannot retry job with status %s", currentStatus) } currentRetry := int64(0) if job.RetryCount != nil { currentRetry = *job.RetryCount } maxRetries := int64(3) if job.MaxRetries != nil { maxRetries = *job.MaxRetries } if currentRetry >= maxRetries { return nil, fmt.Errorf("max retries (%d) exceeded", maxRetries) } pending := string(domain.JobStatusPending) cancelled := false progress := 0.0 now := time.Now() job.Status = &pending job.Cancelled = &cancelled job.RetryCount = int64Ptr(currentRetry + 1) job.Progress = &progress job.AgentID = nil job.UpdatedAt = &now if err := query.Job.WithContext(ctx).Save(job); err != nil { return nil, err } cfg := parseJobConfig(job.Config) if err := syncVideoStatus(ctx, cfg.VideoID, domain.JobStatusPending); err != nil { return nil, err } domainJob := toDomainJob(job) if err := s.queue.Enqueue(ctx, domainJob); err != nil { return nil, err } _ = s.pubsub.PublishJobUpdate(ctx, jobID, pending, cfg.VideoID) return domainJob, nil } func (s *JobService) UpdateJobProgress(ctx context.Context, jobID string, progress float64) error { job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() if err != nil { return err } now := time.Now() job.Progress = float64Ptr(progress) job.UpdatedAt = &now if err := query.Job.WithContext(ctx).Save(job); err != nil { return err } return s.pubsub.Publish(ctx, jobID, "", progress) } func (s *JobService) ProcessLog(ctx context.Context, jobID string, logData []byte) error { line := string(logData) re := regexp.MustCompile(`out_time_us=(\d+)`) matches := re.FindStringSubmatch(line) var progress float64 if len(matches) > 1 { us, _ := strconv.ParseInt(matches[1], 10, 64) if us > 0 { progress = float64(us) / 1000000.0 } } job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() if err != nil { return err } existingLogs := "" if job.Logs != nil { existingLogs = *job.Logs } newLog := line if !strings.HasSuffix(newLog, "\n") { newLog += "\n" } existingLogs += newLog if len(existingLogs) > 10*1024*1024 { existingLogs = existingLogs[len(existingLogs)-8*1024*1024:] } now := time.Now() job.Logs = &existingLogs if progress > 0 { job.Progress = float64Ptr(progress) } job.UpdatedAt = &now if err := query.Job.WithContext(ctx).Save(job); err != nil { return err } return s.pubsub.Publish(ctx, jobID, line, progress) } func syncVideoStatus(ctx context.Context, videoID string, status domain.JobStatus) error { videoID = strings.TrimSpace(videoID) if videoID == "" { return nil } statusValue := "processing" processingStatus := "PROCESSING" switch status { case domain.JobStatusSuccess: statusValue = "ready" processingStatus = "READY" case domain.JobStatusFailure, domain.JobStatusCancelled: statusValue = "failed" processingStatus = "FAILED" } _, err := query.Video.WithContext(ctx). Where(query.Video.ID.Eq(videoID)). Updates(map[string]any{"status": statusValue, "processing_status": processingStatus}) return err } func (s *JobService) PublishSystemResources(ctx context.Context, agentID string, data []byte) error { return s.pubsub.PublishResource(ctx, agentID, data) }