package jobs import ( "context" "encoding/json" "fmt" "sync" ) // Handler defines the function signature for executing a job. type Handler func(ctx context.Context, payload json.RawMessage) error // WithResultHandler defines the function signature for executing a job and // returning import evidence when available. type WithResultHandler func(ctx context.Context, payload json.RawMessage) (Result, error) // Result carries the import outcome from a job handler. It is the single // source of truth for import evidence; scheduler.ItemResult.ImportSummary // wraps this same struct so the types align across packages. type Result struct { Bars int Instruments int MissingBars int GapBars int DuplicateBars int ProviderDelay int } // Runner manages the registration and execution of worker job handlers. type Runner struct { mu sync.RWMutex handlers map[Kind]Handler handlersWithResult map[Kind]WithResultHandler } // NewRunner creates a new Runner instance. func NewRunner() *Runner { return &Runner{ handlers: make(map[Kind]Handler), handlersWithResult: make(map[Kind]WithResultHandler), } } // Register registers a handler for a specific job kind. func (r *Runner) Register(kind Kind, handler Handler) { r.mu.Lock() defer r.mu.Unlock() r.handlers[kind] = handler } // RegisterWithResult registers a handler that can also return import evidence. // Both Register and RegisterWithResult must be called for the same kind // so that Execute and ExecuteWithResult both work. func (r *Runner) RegisterWithResult(kind Kind, handler WithResultHandler) { r.mu.Lock() defer r.mu.Unlock() r.handlersWithResult[kind] = handler } // Len returns the number of registered handlers. func (r *Runner) Len() int { r.mu.RLock() defer r.mu.RUnlock() return len(r.handlers) } // Execute dispatches the job to its registered handler and executes it. // It checks context cancellation and handles panics gracefully. func (r *Runner) Execute(ctx context.Context, job Job) error { r.mu.RLock() handler, exists := r.handlers[job.Kind] r.mu.RUnlock() if !exists { return fmt.Errorf("unknown job kind: %s", job.Kind) } // Check context before starting if err := ctx.Err(); err != nil { return err } return r.executeWithPanicRecovery(ctx, handler, job.Payload) } // ExecuteWithResult dispatches the job to its registered handler and tries to // obtain an import Result. If a WithResultHandler is registered it returns the // evidence along with any error; otherwise it returns (Result{}, false, nil) // so callers can distinguish "no result handler" (hasResult=false, err=nil) // from a real execution error (hasResult=false, err!=nil). // // Contract: the three-value return semantics are: // - (res, true, nil) → handler ran and returned result // - (Result{}, false, nil) → no WithResultHandler registered (legacy fallback) // - (Result{}, false, err) → handler ran but returned an error // // Callers that only have the two-value Executor interface should use Execute() // instead. ExecuteWithResult is the sole interface for result-returning paths. func (r *Runner) ExecuteWithResult(ctx context.Context, job Job) (Result, bool, error) { r.mu.RLock() wr, hasWithResult := r.handlersWithResult[job.Kind] r.mu.RUnlock() if !hasWithResult { return Result{}, false, nil } // Check context before starting if err := ctx.Err(); err != nil { return Result{}, false, err } res, err := r.executeWithResultPanicRecovery(ctx, wr, job.Payload) if err != nil { return Result{}, false, err } return res, true, nil } // ExecuteWithResultFallback is a convenience wrapper that provides Execute()-level // fallback for two-value callers. It is NOT part of the Executor interface and // should only be used internally where the old pattern (res, ok) was consumed. // Prefer the three-value ExecuteWithResult to preserve error information. func (r *Runner) ExecuteWithResultFallback(ctx context.Context, job Job) (Result, bool) { res, hasResult, err := r.ExecuteWithResult(ctx, job) if err != nil { // Error path: treat as no-result so legacy callers can fallback to Execute. return Result{}, false } return res, hasResult } // executeWithResultPanicRecovery wraps a WithResultHandler with panic // recovery so that panics surface as typed errors rather than collapsing // into (Result{}, false, nil). func (r *Runner) executeWithResultPanicRecovery(ctx context.Context, handler WithResultHandler, payload json.RawMessage) (res Result, err error) { defer func() { if rec := recover(); rec != nil { err = fmt.Errorf("job panicked: %v", rec) } }() return handler(ctx, payload) } func (r *Runner) executeWithPanicRecovery(ctx context.Context, handler Handler, payload json.RawMessage) (err error) { defer func() { if rec := recover(); rec != nil { err = fmt.Errorf("job panicked: %v", rec) } }() return handler(ctx, payload) }