Просмотр исходного кода

fix(worker): prevent panic when jobs are pushed during graceful shutdown

Pool.Shutdown() closed the job queue channel, but jobs can still be
pushed while workers are draining: the feed scheduler ticker goroutine
is never cancelled, and the UI and API refresh handlers push from
detached goroutines that outlive the HTTP server shutdown. Any of them
sending on the closed queue panicked the process mid-shutdown.

Keep the queue channel open and instead close a dedicated shutdown
channel, guarded by sync.Once. Push now selects between delivering a
job and the shutdown signal, discarding jobs once shutdown begins, and
workers select between the queue and the shutdown signal, so they
still finish their current job before Shutdown returns.
Fred 1 месяц назад
Родитель
Сommit
c8c414e8fb
3 измененных файлов с 92 добавлено и 10 удалено
  1. 17 7
      internal/worker/pool.go
  2. 65 0
      internal/worker/pool_test.go
  3. 10 3
      internal/worker/worker.go

+ 17 - 7
internal/worker/pool.go

@@ -12,33 +12,43 @@ import (
 
 // Pool manages a set of background workers that process feed refresh jobs.
 type Pool struct {
-	queue chan model.Job
-	wg    sync.WaitGroup
+	queue        chan model.Job
+	shutdown     chan struct{}
+	shutdownOnce sync.Once
+	wg           sync.WaitGroup
 }
 
 // Push sends a list of jobs to the queue.
+// Jobs pushed after Shutdown are discarded.
 func (p *Pool) Push(jobs model.JobList) {
 	for _, job := range jobs {
-		p.queue <- job
+		select {
+		case p.queue <- job:
+		case <-p.shutdown:
+			return
+		}
 	}
 }
 
-// Shutdown closes the job queue and waits for all workers to finish their current jobs.
+// Shutdown stops accepting new jobs and waits for all workers to finish their current jobs.
 func (p *Pool) Shutdown() {
-	close(p.queue)
+	p.shutdownOnce.Do(func() {
+		close(p.shutdown)
+	})
 	p.wg.Wait()
 }
 
 // NewPool creates a pool of background workers.
 func NewPool(store *storage.Storage, nbWorkers int) *Pool {
 	workerPool := &Pool{
-		queue: make(chan model.Job),
+		queue:    make(chan model.Job),
+		shutdown: make(chan struct{}),
 	}
 
 	for i := range nbWorkers {
 		workerPool.wg.Add(1)
 		worker := &worker{id: i, store: store}
-		go worker.Run(workerPool.queue, &workerPool.wg)
+		go worker.Run(workerPool.queue, workerPool.shutdown, &workerPool.wg)
 	}
 
 	return workerPool

+ 65 - 0
internal/worker/pool_test.go

@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package worker // import "miniflux.app/v2/internal/worker"
+
+import (
+	"testing"
+	"time"
+
+	"miniflux.app/v2/internal/model"
+)
+
+func TestPushAfterShutdownDiscardsJobs(t *testing.T) {
+	pool := NewPool(nil, 2)
+	pool.Shutdown()
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		pool.Push(model.JobList{{FeedID: 1}, {FeedID: 2}})
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(5 * time.Second):
+		t.Fatal("Push blocked after Shutdown instead of discarding jobs")
+	}
+}
+
+func TestShutdownUnblocksPendingPush(t *testing.T) {
+	pool := NewPool(nil, 0)
+
+	pushed := make(chan struct{})
+	go func() {
+		defer close(pushed)
+		pool.Push(model.JobList{{FeedID: 1}})
+	}()
+
+	// Give Push time to block on the unbuffered queue before shutting down.
+	time.Sleep(10 * time.Millisecond)
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		pool.Shutdown()
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(5 * time.Second):
+		t.Fatal("Shutdown deadlocked while a Push was pending")
+	}
+
+	select {
+	case <-pushed:
+	case <-time.After(5 * time.Second):
+		t.Fatal("Push remained blocked after Shutdown")
+	}
+}
+
+func TestShutdownIsIdempotent(t *testing.T) {
+	pool := NewPool(nil, 1)
+	pool.Shutdown()
+	pool.Shutdown()
+}

+ 10 - 3
internal/worker/worker.go

@@ -20,15 +20,22 @@ type worker struct {
 	store *storage.Storage
 }
 
-// Run processes feed refresh jobs from the channel until it is closed.
-func (w *worker) Run(c <-chan model.Job, wg *sync.WaitGroup) {
+// Run processes feed refresh jobs from the channel until the pool is shut down.
+func (w *worker) Run(c <-chan model.Job, shutdown <-chan struct{}, wg *sync.WaitGroup) {
 	defer wg.Done()
 
 	slog.Debug("Worker started",
 		slog.Int("worker_id", w.id),
 	)
 
-	for job := range c {
+	for {
+		var job model.Job
+		select {
+		case <-shutdown:
+			return
+		case job = <-c:
+		}
+
 		slog.Debug("Job received by worker",
 			slog.Int("worker_id", w.id),
 			slog.Int64("user_id", job.UserID),