pool_test.go
| 1 | package worker |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "vidarchive/internal/config" |
| 10 | "vidarchive/internal/database" |
| 11 | "vidarchive/internal/models" |
| 12 | "vidarchive/internal/repository" |
| 13 | "vidarchive/internal/service" |
| 14 | ) |
| 15 | |
| 16 | // newTestPool builds a pool over a real service and database, with yt-dlp |
| 17 | // replaced by a script that reports it has started and then blocks. scratch is |
| 18 | // where that script writes its marker. |
| 19 | func newTestPool(t *testing.T) (pool *Pool, repo *repository.DownloadRepository, scratch string) { |
| 20 | t.Helper() |
| 21 | |
| 22 | root := t.TempDir() |
| 23 | cfg := &config.Config{ |
| 24 | DBPath: ":memory:", |
| 25 | LibraryDir: filepath.Join(root, "library"), |
| 26 | TempDir: filepath.Join(root, "temp"), |
| 27 | FFmpegPath: "ffmpeg", |
| 28 | FFprobePath: "ffprobe", |
| 29 | } |
| 30 | for _, dir := range []string{cfg.LibraryDir, cfg.TempDir} { |
| 31 | if err := os.MkdirAll(dir, 0755); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // The script must not exit on its own: the point of these tests is that Stop |
| 37 | // interrupts a download rather than waiting one out. |
| 38 | cfg.YTDLPPath = filepath.Join(root, "yt-dlp") |
| 39 | script := "#!/bin/sh\ntouch " + filepath.Join(root, "started") + "\nsleep 300\n" |
| 40 | if err := os.WriteFile(cfg.YTDLPPath, []byte(script), 0755); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | |
| 44 | db, err := database.New(cfg) |
| 45 | if err != nil { |
| 46 | t.Fatalf("init db: %v", err) |
| 47 | } |
| 48 | t.Cleanup(func() { db.Close() }) |
| 49 | |
| 50 | repo = repository.NewDownloadRepository(db) |
| 51 | downloadSvc := service.NewDownloadService( |
| 52 | repo, |
| 53 | service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath), |
| 54 | service.NewPresetService(repository.NewPresetRepository(db)), |
| 55 | service.NewSettingsService(repository.NewSettingsRepository(db)), |
| 56 | service.NewSubscriptionService(repository.NewSubscriptionRepository(db), cfg), |
| 57 | cfg, |
| 58 | ) |
| 59 | |
| 60 | return New(downloadSvc, 2), repo, root |
| 61 | } |
| 62 | |
| 63 | func waitForFile(t *testing.T, path string) { |
| 64 | t.Helper() |
| 65 | deadline := time.Now().Add(10 * time.Second) |
| 66 | for time.Now().Before(deadline) { |
| 67 | if _, err := os.Stat(path); err == nil { |
| 68 | return |
| 69 | } |
| 70 | time.Sleep(5 * time.Millisecond) |
| 71 | } |
| 72 | t.Fatalf("timed out waiting for %s", path) |
| 73 | } |
| 74 | |
| 75 | // Stop must return while a download is still running. If it waited for yt-dlp |
| 76 | // to finish on its own, a shutdown would hang for as long as the download takes. |
| 77 | func TestStopReturnsWhileDownloadRunning(t *testing.T) { |
| 78 | pool, repo, scratch := newTestPool(t) |
| 79 | |
| 80 | d := &models.Download{URL: "https://example.com/v", Status: "queued"} |
| 81 | if err := repo.Create(d); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | |
| 85 | pool.Start() |
| 86 | pool.Submit(d) |
| 87 | waitForFile(t, filepath.Join(scratch, "started")) |
| 88 | |
| 89 | stopped := make(chan struct{}) |
| 90 | go func() { |
| 91 | pool.Stop() |
| 92 | close(stopped) |
| 93 | }() |
| 94 | |
| 95 | select { |
| 96 | case <-stopped: |
| 97 | case <-time.After(30 * time.Second): |
| 98 | t.Fatal("Stop did not return while a download was running") |
| 99 | } |
| 100 | |
| 101 | // The row stays "downloading" after a shutdown so the next start resumes it. |
| 102 | got, err := repo.GetByID(d.ID) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if got.Status != "downloading" { |
| 107 | t.Errorf("status = %q, want downloading", got.Status) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // Stop is called from the shutdown path and may race with an already-stopped |
| 112 | // pool; calling it twice must not panic or block. |
| 113 | func TestStopIsIdempotent(t *testing.T) { |
| 114 | pool, _, _ := newTestPool(t) |
| 115 | pool.Start() |
| 116 | |
| 117 | done := make(chan struct{}) |
| 118 | go func() { |
| 119 | pool.Stop() |
| 120 | pool.Stop() |
| 121 | close(done) |
| 122 | }() |
| 123 | |
| 124 | select { |
| 125 | case <-done: |
| 126 | case <-time.After(30 * time.Second): |
| 127 | t.Fatal("second Stop blocked") |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // A download submitted after shutdown must be dropped rather than sent to a |
| 132 | // channel nobody is reading. |
| 133 | func TestSubmitAfterStopIsNoOp(t *testing.T) { |
| 134 | pool, repo, _ := newTestPool(t) |
| 135 | pool.Start() |
| 136 | pool.Stop() |
| 137 | |
| 138 | d := &models.Download{URL: "https://example.com/v", Status: "queued"} |
| 139 | if err := repo.Create(d); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | |
| 143 | done := make(chan struct{}) |
| 144 | go func() { |
| 145 | pool.Submit(d) |
| 146 | close(done) |
| 147 | }() |
| 148 | |
| 149 | select { |
| 150 | case <-done: |
| 151 | case <-time.After(10 * time.Second): |
| 152 | t.Fatal("Submit blocked after Stop") |
| 153 | } |
| 154 | if len(pool.queue) != 0 { |
| 155 | t.Errorf("queue holds %d items, want 0 after Stop", len(pool.queue)) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // Submit is called from an HTTP handler, so a full buffer must never block it: |
| 160 | // the row stays queued in the database and the queue checker picks it up later. |
| 161 | func TestSubmitDoesNotBlockWhenBufferFull(t *testing.T) { |
| 162 | pool, _, _ := newTestPool(t) |
| 163 | // Deliberately no Start: nothing drains the buffer. |
| 164 | |
| 165 | done := make(chan struct{}) |
| 166 | go func() { |
| 167 | for i := 0; i < cap(pool.queue)*2; i++ { |
| 168 | pool.Submit(&models.Download{ID: int64(i + 1), URL: "https://example.com/v"}) |
| 169 | } |
| 170 | close(done) |
| 171 | }() |
| 172 | |
| 173 | select { |
| 174 | case <-done: |
| 175 | case <-time.After(10 * time.Second): |
| 176 | t.Fatal("Submit blocked on a full buffer") |
| 177 | } |
| 178 | if len(pool.queue) != cap(pool.queue) { |
| 179 | t.Errorf("queue holds %d items, want the buffer to be full at %d", len(pool.queue), cap(pool.queue)) |
| 180 | } |
| 181 | } |
| 182 |