package worker import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time" "wucher/internal/config" "wucher/internal/shared/pkg/metrics" "wucher/internal/queue" ) type FileProcessingRunner struct { worker *queue.FileProcessingWorker consumer queue.QueueConsumer dispatcher *FileProcessingOutboxDispatcher logger *slog.Logger monitorAddr string depthPollInterval time.Duration shutdownTimeout time.Duration backend string monitorServer *http.Server } func NewFileProcessingRunner( cfg config.FileQueueConfig, consumer queue.QueueConsumer, serializer queue.FileProcessingMessageSerializer, handler queue.FileProcessingJobHandler, idempotency queue.IdempotencyStore, dispatcher *FileProcessingOutboxDispatcher, logger *slog.Logger, ) *FileProcessingRunner { if logger == nil { logger = slog.Default() } metrics := queue.NewMetrics() health := queue.NewHealthReporter(cfg.WorkerHealthErrorThreshold) worker := queue.NewFileProcessingWorker(queue.WorkerConfig{ Concurrency: cfg.WorkerConcurrency, MaxBatchSize: cfg.WorkerMaxBatchSize, ProcessTimeout: cfg.WorkerProcessTimeout, ProcessingTTL: cfg.ProcessingTTL, IdempotencyTTL: cfg.IdempotencyTTL, PollErrorDelay: cfg.WorkerBackoffMin, MetricsComponent: "file_worker", }, consumer, serializer, handler, queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay), idempotency, metrics, health, logger) backend := "" if consumer != nil { backend = consumer.Backend() } return &FileProcessingRunner{ worker: worker, consumer: consumer, dispatcher: dispatcher, logger: logger, monitorAddr: cfg.WorkerMonitorAddr, depthPollInterval: cfg.WorkerDepthPollInterval, shutdownTimeout: cfg.WorkerShutdownTimeout, backend: backend, } } func (r *FileProcessingRunner) Start(ctx context.Context) error { if r.worker == nil { return errors.New("worker is required") } runCtx, cancelRun := context.WithCancel(ctx) defer cancelRun() if err := r.startMonitor(); err != nil { return err } pollerDone := make(chan struct{}) go func() { defer close(pollerDone) r.runDepthPoller(runCtx) }() workerErrCh := make(chan error, 1) go func() { workerErrCh <- r.worker.Run(runCtx) }() var dispatcherErrCh chan error if r.dispatcher != nil { dispatcherErrCh = make(chan error, 1) go func() { dispatcherErrCh <- r.dispatcher.Run(runCtx) }() } var err error select { case err = <-workerErrCh: cancelRun() if dispatcherErrCh != nil { if dispatcherErr := <-dispatcherErrCh; err == nil { err = dispatcherErr } } case err = <-dispatcherErrCh: cancelRun() if workerErr := <-workerErrCh; err == nil { err = workerErr } } cancelRun() shutdownCtx, cancel := context.WithTimeout(context.Background(), r.shutdownTimeout) defer cancel() if r.monitorServer != nil { _ = r.monitorServer.Shutdown(shutdownCtx) } if r.consumer != nil { _ = r.consumer.Close() } <-pollerDone return err } func (r *FileProcessingRunner) startMonitor() error { if r.monitorAddr == "" { return nil } mux := http.NewServeMux() mux.HandleFunc("/health", r.handleHealth) mux.Handle("/metrics", metrics.PromHTTPHandler()) r.monitorServer = &http.Server{ Addr: r.monitorAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } go func() { r.logger.Info("file worker monitor listening", slog.String("addr", r.monitorAddr), slog.String("backend", r.backend), ) if err := r.monitorServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { r.logger.Error("file worker monitor failed", slog.Any("error", err)) } }() return nil } func (r *FileProcessingRunner) runDepthPoller(ctx context.Context) { if r.depthPollInterval <= 0 { return } ticker := time.NewTicker(r.depthPollInterval) defer ticker.Stop() for { refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second) _ = r.worker.RefreshQueueDepth(refreshCtx) cancel() select { case <-ctx.Done(): return case <-ticker.C: } } } func (r *FileProcessingRunner) handleHealth(w http.ResponseWriter, _ *http.Request) { snapshot := struct { Backend string `json:"backend"` Health queue.HealthSnapshot `json:"health"` Metrics queue.MetricsSnapshot `json:"metrics"` Outbox *OutboxMetricsSnapshot `json:"outbox,omitempty"` }{ Backend: r.backend, Health: r.worker.Health().Snapshot(), Metrics: r.worker.Metrics().Snapshot(), } if r.dispatcher != nil { snapshot.Outbox = ptr(r.dispatcher.Metrics()) } statusCode := http.StatusOK if snapshot.Health.Status == "degraded" { statusCode = http.StatusServiceUnavailable } writeFileWorkerJSON(w, statusCode, snapshot) } func writeFileWorkerJSON(w http.ResponseWriter, statusCode int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) _ = json.NewEncoder(w).Encode(payload) }