Files
bookhoard/internal/services/worker.go
T
john-okeefe 936a48405b refactor(background): parameterize sync queue and worker pool constructors
Split each constructor into a default-args wrapper and a config-accepting
variant so the sync queue interval/batch size and the worker pool size/
queue cap can be sourced from the settings registry at startup. These
values are constructed once at boot, so they are tagged requires_restart
in the admin UI.

queue.go:
- NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the
  flush interval and batch size as parameters; NewSyncQueueProcessor
  becomes a thin wrapper with the historical 5s / 50 defaults.

worker.go:
- NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the
  queue capacity as a parameter; NewWorker becomes a thin wrapper with
  the historical cap of 100.

No behavior change for existing callers; main.go will switch to the
config-accepting variants in a follow-up wiring commit.
2026-08-10 08:02:02 -04:00

1006 lines
24 KiB
Go

package services
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"context"
"fmt"
"net/http"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type JobStatus string
const (
JobStatusPending JobStatus = "pending"
JobStatusRunning JobStatus = "running"
JobStatusCompleted JobStatus = "completed"
JobStatusFailed JobStatus = "failed"
JobStatusCancelled JobStatus = "cancelled"
)
type JobType string
const (
JobTypeScan JobType = "scan"
JobTypeSetFolders JobType = "set_folders"
JobTypeDirectoryScan JobType = "directory_scan"
JobTypeImport JobType = "import"
JobTypeConvert JobType = "convert"
JobTypeThumbnails JobType = "thumbnails"
JobTypeBackup JobType = "backup"
JobTypeAnalytics JobType = "analytics"
)
var WorkerInstance *Worker
func (w *Worker) Enqueue(job *Job) {
select {
case w.jobQueue <- job:
default:
fmt.Printf("Worker queue full, rejecting job: %s\n", job.ID)
}
}
type Job struct {
ID string
Type JobType
UserID string
Priority int
Params map[string]interface{}
Status JobStatus
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
Error error
Result interface{}
Context context.Context
ProgressCallback func(progress float64, filesScanned, newItems, errors int)
}
func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors int) {
if j.ProgressCallback != nil {
j.ProgressCallback(progress, filesScanned, newItems, errors)
}
}
type JobResult struct {
JobID string `json:"job_id"`
Status JobStatus `json:"status"`
Error string `json:"error"`
Result interface{} `json:"result"`
Progress float64 `json:"progress"`
FilesScanned int `json:"files_scanned"`
NewItems int `json:"new_items"`
Errors int `json:"errors"`
}
// ScanJobResult represents the result of a scan job
type ScanJobResult struct {
Message string `json:"message"`
LibraryID string `json:"library_id"`
FilesScanned int `json:"files_scanned"`
NewItems int `json:"new_items"`
Errors int `json:"errors"`
}
// DirectoryScanJobResult represents the result of a directory scan job
type DirectoryScanJobResult struct {
Message string `json:"message"`
FilesScanned int `json:"files_scanned"`
NewItems int `json:"new_items"`
Errors int `json:"errors"`
}
// SetFoldersJobResult represents the result of a set folders job
type SetFoldersJobResult struct {
Message string `json:"message"`
Folders []string `json:"folders"`
}
// ImportJobResult represents the result of an import job
type ImportJobResult struct {
Message string `json:"message"`
Items int `json:"items_imported"`
}
// ConvertJobResult represents the result of a convert job
type ConvertJobResult struct {
Message string `json:"message"`
Converted int `json:"converted"`
Errors int `json:"errors"`
}
// ThumbnailsJobResult represents the result of a thumbnail generation job
type ThumbnailsJobResult struct {
Message string `json:"message"`
LibraryID string `json:"library_id"`
TotalItems int `json:"total_items"`
Processed int `json:"processed"`
NewThumbnails int `json:"new_thumbnails"`
Errors int `json:"errors"`
}
// BackupJobResult represents the result of a backup job
type BackupJobResult struct {
Message string `json:"message"`
BackupPath string `json:"backup_path"`
Size int64 `json:"size"`
}
// AnalyticsJobResult represents the result of an analytics job
type AnalyticsJobResult struct {
Message string `json:"message"`
ReportPath string `json:"report_path"`
}
type Worker struct {
jobQueue chan *Job
results map[string]*JobResult
connManager *wsync.ConnectionManager
mu sync.RWMutex
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
shuttingDown atomic.Bool
}
func (w *Worker) HasActiveScans() bool {
w.mu.RLock()
defer w.mu.RUnlock()
for _, result := range w.results {
if result.Status == "running" {
return true
}
}
return false
}
func (w *Worker) GetActiveJobCount() int {
w.mu.RLock()
defer w.mu.RUnlock()
count := 0
for _, result := range w.results {
if result.Status == "running" || result.Status == "pending" {
count++
}
}
return count
}
func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker {
return NewWorkerWithConfig(numWorkers, 100, connManager)
}
// NewWorkerWithConfig constructs a worker pool with the given worker count and
// job-queue capacity. Used at startup to source values from the settings
// registry.
func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.ConnectionManager) *Worker {
ctx, cancel := context.WithCancel(context.Background())
w := &Worker{
jobQueue: make(chan *Job, queueCap),
results: make(map[string]*JobResult),
ctx: ctx,
cancel: cancel,
}
for i := 0; i < numWorkers; i++ {
w.wg.Add(1)
go w.worker()
}
w.connManager = connManager
return w
}
func (w *Worker) worker() {
defer w.wg.Done()
for {
select {
case job := <-w.jobQueue:
if job == nil {
return
}
// Recover from any panic inside a job so a single failing job can
// never crash the whole worker goroutine (and thus the process).
func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[WORKER] panic in job %s (%s): %v\n", job.ID, job.Type, r)
w.mu.Lock()
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: JobStatusFailed,
Error: fmt.Sprintf("panic: %v", r),
}
w.mu.Unlock()
}
}()
w.processJob(job)
}()
case <-w.ctx.Done():
return
}
}
}
func (w *Worker) processJob(job *Job) {
w.mu.Lock()
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: JobStatusRunning,
}
w.mu.Unlock()
started := time.Now()
job.StartedAt = &started
w.mu.Lock()
if result, exists := w.results[job.ID]; exists {
result.Status = JobStatusRunning
}
w.mu.Unlock()
var err error
var result interface{}
switch job.Type {
case JobTypeScan:
result, err = w.processScanJob(job)
case JobTypeSetFolders:
result, err = w.processSetFoldersJob(job)
case JobTypeDirectoryScan:
result, err = w.processDirectoryScanJob(job)
case JobTypeImport:
result, err = w.processImportJob(job)
case JobTypeConvert:
result, err = w.processConvertJob(job)
case JobTypeThumbnails:
result, err = w.processThumbnailsJob(job)
case JobTypeBackup:
result, err = w.processBackupJob(job)
case JobTypeAnalytics:
result, err = w.processAnalyticsJob(job)
default:
err = fmt.Errorf("unknown job type: %s", job.Type)
}
completed := time.Now()
job.CompletedAt = &completed
job.Error = err
job.Result = result
w.mu.Lock()
status := JobStatusCompleted
if err != nil {
status = JobStatusFailed
}
if job.Context != nil && job.Context.Err() != nil {
status = JobStatusCancelled
}
var filesScanned, newItems, errors int
if result != nil {
switch r := result.(type) {
case *ScanJobResult:
filesScanned = r.FilesScanned
newItems = r.NewItems
errors = r.Errors
case *DirectoryScanJobResult:
filesScanned = r.FilesScanned
newItems = r.NewItems
errors = r.Errors
case *ThumbnailsJobResult:
filesScanned = r.Processed
newItems = r.NewThumbnails
errors = r.Errors
// Other job types don't report these metrics
default:
// Keep zeros for job types that don't report stats
}
}
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: status,
Error: func() string {
if err != nil {
return err.Error()
} else {
return ""
}
}(),
Result: result,
Progress: 1.0,
FilesScanned: filesScanned,
NewItems: newItems,
Errors: errors,
}
if job.Type == JobTypeScan && w.connManager != nil && job.UserID != "" {
w.connManager.BroadcastToUser(job.UserID, wsync.BroadcastMessage{
Type: wsync.MessageTypeScanComplete,
Data: map[string]interface{}{
"job_id": job.ID,
"files_scanned": filesScanned,
"new_items": newItems,
"errors": errors,
},
})
}
w.mu.Unlock()
}
func (w *Worker) processScanJob(job *Job) (interface{}, error) {
libraryID, ok := job.Params["library_id"].(string)
if !ok {
return nil, fmt.Errorf("library_id required")
}
folders, ok := job.Params["folders"].([]string)
if !ok {
return nil, fmt.Errorf("folders required")
}
adminID, ok := job.Params["admin_id"].(string)
if !ok {
return nil, fmt.Errorf("admin_id required")
}
db, ok := job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database queries required")
}
force := false
if forceVal, ok := job.Params["force"].(bool); ok {
force = forceVal
}
scanner := NewMediaScanner(db)
defer scanner.Close()
scanner.job = job
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
w.mu.Lock()
defer w.mu.Unlock()
if result, exists := w.results[job.ID]; exists {
result.Progress = progress
result.FilesScanned = filesScanned
result.NewItems = newItems
result.Errors = errors
}
// Broadcast via WebSocket to user
if w.connManager != nil && job.UserID != "" {
msg := wsync.BroadcastMessage{
Type: wsync.MessageTypeScanProgress,
Data: map[string]interface{}{
"job_id": job.ID,
"progress": progress,
"files_scanned": filesScanned,
"new_items": newItems,
"errors": errors,
},
}
w.connManager.BroadcastToUser(job.UserID, msg)
}
}
if err := scanner.SetFolders(folders, false); err != nil {
return nil, err
}
var adminUUID pgtype.UUID
if err := adminUUID.Scan(adminID); err != nil {
return nil, err
}
scanner.SetAdminID(adminUUID)
scanner.SetForce(force)
var libraryUUID pgtype.UUID
if err := libraryUUID.Scan(libraryID); err != nil {
return nil, err
}
scanner.SetLibraryID(libraryUUID)
if err := scanner.ScanFolders(job.Context); err != nil {
return nil, err
}
totalFiles, newItems, errors := scanner.GetStats()
return &ScanJobResult{
Message: "scan completed",
LibraryID: libraryID,
FilesScanned: totalFiles,
NewItems: newItems,
Errors: errors,
}, nil
}
func (w *Worker) processImportJob(job *Job) (interface{}, error) {
// Extract parameters
sourceParam, ok := job.Params["source"]
if !ok {
return nil, fmt.Errorf("source parameter required")
}
source, ok := sourceParam.(string)
if !ok {
return nil, fmt.Errorf("source must be a string")
}
libraryIDParam, ok := job.Params["library_id"]
if !ok {
return nil, fmt.Errorf("library_id parameter required")
}
libraryID, ok := libraryIDParam.(string)
if !ok {
return nil, fmt.Errorf("library_id must be a string")
}
_, ok = job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database parameter required")
}
// Import based on source type
var result map[string]interface{}
switch source {
case "opds":
// Import from OPDS feed
feedURLParam, ok := job.Params["feed_url"]
if !ok {
return nil, fmt.Errorf("feed_url parameter required for OPDS import")
}
feedURL, ok := feedURLParam.(string)
if !ok {
return nil, fmt.Errorf("feed_url must be a string")
}
// Fetch OPDS feed
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(feedURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch OPDS feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OPDS feed returned status %d", resp.StatusCode)
}
// Parse OPDS feed (simplified - would need OPDS parser library)
// For now, just return the feed URL as the result
result = map[string]interface{}{
"message": "OPDS import initiated",
"source": "opds",
"feed_url": feedURL,
"library_id": libraryID,
"note": "OPDS parsing not yet implemented",
}
case "calibre":
// Import from Calibre library
calibreDBParam, ok := job.Params["calibre_db_path"]
if !ok {
return nil, fmt.Errorf("calibre_db_path parameter required for Calibre import")
}
calibreDBPath, ok := calibreDBParam.(string)
if !ok {
return nil, fmt.Errorf("calibre_db_path must be a string")
}
// Import from Calibre database (requires SQLite access)
// For now, just return the path as the result
result = map[string]interface{}{
"message": "Calibre import initiated",
"source": "calibre",
"calibre_db_path": calibreDBPath,
"library_id": libraryID,
"note": "Calibre import not yet implemented",
}
default:
return nil, fmt.Errorf("unsupported import source: %s (supported: opds, calibre)", source)
}
return result, nil
}
func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) {
// Extract parameters
foldersParam, ok := job.Params["folders"]
if !ok {
return nil, fmt.Errorf("folders parameter required")
}
folders, ok := foldersParam.([]string)
if !ok {
return nil, fmt.Errorf("folders must be a string array")
}
db, ok := job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database parameter required")
}
// Create scanner and configure folders
scanner := NewMediaScanner(db)
defer scanner.Close()
if err := scanner.SetFolders(folders, false); err != nil {
return nil, fmt.Errorf("failed to set folders: %w", err)
}
// Return success result
return &SetFoldersJobResult{
Message: "folders configured successfully",
Folders: folders,
}, nil
}
func (w *Worker) processConvertJob(job *Job) (interface{}, error) {
// Extract parameters
mediaIDParam, ok := job.Params["media_id"]
if !ok {
return nil, fmt.Errorf("media_id parameter required")
}
mediaID, ok := mediaIDParam.(string)
if !ok {
return nil, fmt.Errorf("media_id must be a string")
}
targetFormatParam, ok := job.Params["target_format"]
if !ok {
return nil, fmt.Errorf("target_format parameter required")
}
targetFormat, ok := targetFormatParam.(string)
if !ok {
return nil, fmt.Errorf("target_format must be a string")
}
db, ok := job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database parameter required")
}
// Validate target format
if targetFormat != "kepub" {
return nil, fmt.Errorf("unsupported target format: %s (only 'kepub' supported)", targetFormat)
}
ctx := context.Background()
// Get media item
mediaUUID := pgtype.UUID{Bytes: uuid.MustParse(mediaID), Valid: true}
item, err := db.GetMediaItem(ctx, mediaUUID)
if err != nil {
return nil, fmt.Errorf("failed to get media item: %w", err)
}
// Update progress
if job.ProgressCallback != nil {
job.ProgressCallback(0.0, 0, 0, 0)
}
// Check if EPUB
if !strings.HasSuffix(strings.ToLower(item.FilePath), ".epub") {
return nil, fmt.Errorf("only EPUB files can be converted to KEPUB")
}
// Perform conversion
// Note: This would call the actual conversion utility
// For now, return success with the converted path
convertedPath := strings.TrimSuffix(item.FilePath, ".epub") + ".kepub.epub"
// Update progress to complete
if job.ProgressCallback != nil {
job.ProgressCallback(1.0, 1, 1, 0)
}
return map[string]interface{}{
"message": "conversion completed",
"media_id": mediaID,
"source_format": "epub",
"target_format": targetFormat,
"converted_path": convertedPath,
}, nil
}
func (w *Worker) processThumbnailsJob(job *Job) (interface{}, error) {
// Extract parameters
libraryIDParam, ok := job.Params["library_id"]
if !ok {
return nil, fmt.Errorf("library_id parameter required")
}
libraryID, ok := libraryIDParam.(string)
if !ok {
return nil, fmt.Errorf("library_id must be a string")
}
forceParam, forceOk := job.Params["force"]
force := false
if forceOk {
force, ok = forceParam.(bool)
if !ok {
return nil, fmt.Errorf("force must be a boolean")
}
}
db, ok := job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database parameter required")
}
ctx := context.Background()
// Get all items in library
libraryUUID := pgtype.UUID{Bytes: uuid.MustParse(libraryID), Valid: true}
items, err := db.ListMediaItemsByLibrary(ctx, libraryUUID)
if err != nil {
return nil, fmt.Errorf("failed to query library items: %w", err)
}
// Set up progress tracking
totalItems := len(items)
processedItems := 0
newThumbnails := 0
errors := 0
updateProgress := func() {
if job.ProgressCallback != nil {
progress := float64(processedItems) / float64(totalItems)
job.ProgressCallback(progress, processedItems, newThumbnails, errors)
}
}
// Process each item
for _, item := range items {
// Check if already has cover image
if !force && item.CoverImagePath.Valid && len(item.CoverImagePath.String) > 0 {
processedItems++
updateProgress()
continue
}
// Extract thumbnail from file
// Note: This would call the actual thumbnail extraction
// For now, just simulate the operation
// Simulate thumbnail extraction
processedItems++
// In real implementation:
// - Open file (EPUB, PDF, comic)
// - Extract cover image
// - Resize/compress
// - Store in database
// - If successful: newThumbnails++
updateProgress()
}
return &ThumbnailsJobResult{
Message: "thumbnail generation completed",
LibraryID: libraryID,
TotalItems: totalItems,
Processed: processedItems,
NewThumbnails: newThumbnails,
Errors: errors,
}, nil
}
func (w *Worker) processBackupJob(job *Job) (interface{}, error) {
// Extract parameters
backupTypeParam, ok := job.Params["backup_type"]
if !ok {
return nil, fmt.Errorf("backup_type parameter required")
}
backupType, ok := backupTypeParam.(string)
if !ok {
return nil, fmt.Errorf("backup_type must be a string")
}
_, ok = job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database parameter required")
}
// Validate backup type
if backupType != "full" && backupType != "schema_only" {
return nil, fmt.Errorf("backup_type must be 'full' or 'schema_only'")
}
_, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var backupPath string
var timestamp string
if backupType == "schema_only" {
// Dump schema
timestamp = time.Now().Format("20060102_150405")
backupPath = fmt.Sprintf("/backups/schema_%s.sql", timestamp)
// Note: This would call pg_dump to dump schema
// For now, just return the path
} else {
// Full backup
timestamp = time.Now().Format("20060102_150405")
backupPath = fmt.Sprintf("/backups/full_%s.sql", timestamp)
// Note: This would call pg_dump to dump full database
// For now, just return the path
}
return map[string]interface{}{
"message": "backup completed",
"backup_type": backupType,
"backup_path": backupPath,
"timestamp": timestamp,
}, nil
}
func (w *Worker) processAnalyticsJob(job *Job) (interface{}, error) {
// Extract parameters
reportTypeParam, ok := job.Params["report_type"]
if !ok {
return nil, fmt.Errorf("report_type parameter required")
}
reportType, ok := reportTypeParam.(string)
if !ok {
return nil, fmt.Errorf("report_type must be a string")
}
libraryIDParam, libOk := job.Params["library_id"]
db, ok := job.Params["db"].(*database.Queries)
if !ok {
return nil, fmt.Errorf("database parameter required")
}
ctx := context.Background()
var result interface{}
switch reportType {
case "library_stats":
// Library statistics
var libraryID string
if libOk {
libraryID, ok = libraryIDParam.(string)
if !ok {
return nil, fmt.Errorf("library_id must be a string")
}
}
// Query library stats
if libOk {
libraryUUID := pgtype.UUID{Bytes: uuid.MustParse(libraryID), Valid: true}
items, err := db.ListMediaItemsByLibrary(ctx, libraryUUID)
if err != nil {
return nil, fmt.Errorf("failed to query library items: %w", err)
}
// Calculate stats
totalSize := int64(0)
formats := make(map[string]int)
authors := make(map[string]int)
for _, item := range items {
totalSize += item.FileSize.Int64
ext := strings.ToLower(filepath.Ext(item.FilePath))
formats[ext]++
if item.Author.Valid && item.Author.String != "" {
authors[item.Author.String]++
}
}
result = map[string]interface{}{
"report_type": "library_stats",
"library_id": libraryID,
"total_items": len(items),
"total_size": totalSize,
"formats": formats,
"authors": authors,
"top_authors": getTopN(authors, 10),
}
}
case "system_stats":
// System-wide statistics
libraries, err := db.ListLibraries(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query libraries: %w", err)
}
items, err := db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return nil, fmt.Errorf("failed to query items: %w", err)
}
// Calculate system stats
totalSize := int64(0)
formats := make(map[string]int)
for _, item := range items {
totalSize += item.FileSize.Int64
ext := strings.ToLower(filepath.Ext(item.FilePath))
formats[ext]++
}
result = map[string]interface{}{
"report_type": "system_stats",
"total_libraries": len(libraries),
"total_items": len(items),
"total_size": totalSize,
"formats": formats,
}
default:
return nil, fmt.Errorf("unsupported report_type: %s (supported: library_stats, system_stats)", reportType)
}
return result, nil
}
// Helper function to get top N items from a map
func getTopN(m map[string]int, n int) map[string]int {
type kv struct {
key string
value int
}
var ss []kv
for k, v := range m {
ss = append(ss, kv{k, v})
}
sort.Slice(ss, func(i, j int) bool {
return ss[i].value > ss[j].value
})
if len(ss) > n {
ss = ss[:n]
}
result := make(map[string]int)
for _, kv := range ss {
result[kv.key] = kv.value
}
return result
}
func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
// Extract parameters
directoryParam, ok := job.Params["directory"]
if !ok {
return nil, fmt.Errorf("directory parameter required")
}
directory, ok := directoryParam.(string)
if !ok {
return nil, fmt.Errorf("directory must be a string")
}
dbParam, ok := job.Params["db"]
if !ok {
return nil, fmt.Errorf("db parameter required")
}
db, ok := dbParam.(*database.Queries)
if !ok {
return nil, fmt.Errorf("db must be *database.Queries")
}
// Create temporary scanner instance for this job
scanner := NewMediaScanner(db)
defer scanner.Close()
scanner.job = job
// Find which library owns this directory (prefix match for subdirectories)
ctx := context.Background()
libRow, err := db.GetLibraryByFolderPathPrefix(ctx, directory)
if err != nil {
return nil, fmt.Errorf("directory not associated with any library: %s", directory)
}
// Get all folders for this library
folders, err := db.GetLibraryFolders(ctx, libRow.ID)
if err != nil {
return nil, fmt.Errorf("failed to get library folders: %w", err)
}
// Extract folder paths
var folderPaths []string
for _, f := range folders {
folderPaths = append(folderPaths, f.FolderPath)
}
// Configure scanner with folders
if err := scanner.SetFolders(folderPaths, false); err != nil {
return nil, fmt.Errorf("failed to set folders: %w", err)
}
// Now scan the directory
scanner.scanDirectory(ctx, directory)
// Return scan results
return &DirectoryScanJobResult{
Message: fmt.Sprintf("Scanned directory: %s", directory),
FilesScanned: scanner.totalFiles,
NewItems: scanner.newItems,
Errors: scanner.errors,
}, nil
}
func (w *Worker) EnqueueJob(job *Job) error {
if w.shuttingDown.Load() {
return fmt.Errorf("worker is shutting down")
}
select {
case w.jobQueue <- job:
return nil
case <-w.ctx.Done():
return fmt.Errorf("worker is shutting down")
default:
return fmt.Errorf("job queue is full")
}
}
func (w *Worker) GetJobStatus(jobID string) (*JobResult, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
result, exists := w.results[jobID]
return result, exists
}
func (w *Worker) CancelJob(jobID string) error {
w.mu.Lock()
defer w.mu.Unlock()
if result, exists := w.results[jobID]; exists {
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
result.Status = JobStatusCancelled
return nil
}
return fmt.Errorf("job cannot be cancelled")
}
return fmt.Errorf("job not found")
}
func (w *Worker) Shutdown() {
w.shuttingDown.Store(true)
w.cancel()
close(w.jobQueue)
w.wg.Wait()
}