Vellum/internal/jobs/jobs_test.go
mithral df740d3e1a Phase 5: job queue and metadata providers
- jobs: a table-backed queue in the same SQLite file. No Redis, no second
  process, included in the same backup. claim() is a single UPDATE...RETURNING
  so a job cannot run twice even with several workers. Failures back off
  exponentially; a missing handler fails permanently rather than retrying a
  bug.
- metadata: Provider interface plus Open Library and Google Books (both
  keyless). The enricher tries providers in registration order, falls back on
  empty, and continues past a provider error.
- migration 0004: metadata_fetch records the attempt, not just the match, so a
  rescan does not re-hammer a rate-limited API for an item that will never
  match. Errors are always retried; ok/notfound are cached for 30 days.
- enrichment writes provider_ids, updates title/year, links authors through
  people/item_people, and downloads the cover as an asset. The cover download
  runs outside the transaction: a slow image host must not hold a write lock.
- endpoints: queue enrichment per library, and inspect an item's matches.
- web: Enrich button per library.

Verified live against Open Library: "The Hobbit" matched /works/OL27482W,
title and year 1937 written, a 301x500 JPEG cover downloaded and served.
2026-10-09 21:16:07 -04:00

198 lines
4.8 KiB
Go

package jobs
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "jobs.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := core.Migrate(context.Background(), db, migrations.FS, discard()); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func discard() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func stateOf(t *testing.T, db *sql.DB, id int64) (string, int, string) {
t.Helper()
var state, errMsg string
var attempts int
if err := db.QueryRow(
`SELECT state, attempts, error FROM jobs WHERE id = ?`, id).Scan(&state, &attempts, &errMsg); err != nil {
t.Fatal(err)
}
return state, attempts, errMsg
}
func TestRunOnceExecutesHandler(t *testing.T) {
db := testDB(t)
q := New(db, discard())
var got json.RawMessage
q.Register("greet", func(_ context.Context, payload json.RawMessage) error {
got = payload
return nil
})
if err := q.Enqueue(context.Background(), "greet", map[string]string{"name": "ada"}); err != nil {
t.Fatal(err)
}
ran, err := q.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if !ran {
t.Fatal("RunOnce reported nothing to do")
}
if string(got) != `{"name":"ada"}` {
t.Errorf("payload = %s", got)
}
// Nothing left to run.
ran, err = q.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if ran {
t.Error("RunOnce ran a job that was already done")
}
var state string
var n int
if err := db.QueryRow(`SELECT state, count(*) FROM jobs GROUP BY state`).Scan(&state, &n); err != nil {
t.Fatal(err)
}
if state != StateDone || n != 1 {
t.Errorf("state=%s count=%d, want done/1", state, n)
}
}
func TestUnknownKindFailsImmediately(t *testing.T) {
db := testDB(t)
q := New(db, discard())
if err := q.Enqueue(context.Background(), "nobody-handles-this", nil); err != nil {
t.Fatal(err)
}
if _, err := q.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
var state, errMsg string
if err := db.QueryRow(`SELECT state, error FROM jobs`).Scan(&state, &errMsg); err != nil {
t.Fatal(err)
}
if state != StateFailed {
t.Errorf("state = %s, want failed (a missing handler must not retry)", state)
}
if errMsg == "" {
t.Error("failure reason was not recorded")
}
}
func TestFailureBacksOffThenFails(t *testing.T) {
db := testDB(t)
q := New(db, discard())
q.maxAttempts = 2
q.baseBackoff = 30 * 1000 * 1000 * 1000 // 30s
boom := errors.New("transient")
q.Register("flaky", func(context.Context, json.RawMessage) error { return boom })
if err := q.Enqueue(context.Background(), "flaky", nil); err != nil {
t.Fatal(err)
}
// First run fails and reschedules into the future.
if _, err := q.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
state, attempts, errMsg := stateOf(t, db, 1)
if state != StateQueued || attempts != 1 {
t.Fatalf("after first failure: state=%s attempts=%d", state, attempts)
}
if errMsg == "" {
t.Error("failure reason was not recorded")
}
// Not due yet, so nothing runs.
ran, err := q.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if ran {
t.Error("a backed-off job ran before its run_after")
}
// Force it due and run again: attempts is now at the ceiling.
if _, err := db.Exec(`UPDATE jobs SET run_after = '1970-01-01T00:00:00Z'`); err != nil {
t.Fatal(err)
}
if _, err := q.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
state, attempts, _ = stateOf(t, db, 1)
if state != StateFailed {
t.Errorf("after exhausting attempts: state=%s, want failed", state)
}
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
}
func TestEnqueueAfterDelays(t *testing.T) {
db := testDB(t)
q := New(db, discard())
q.Register("later", func(context.Context, json.RawMessage) error { return nil })
if err := q.EnqueueAfter(context.Background(), "later", nil, 60*1000*1000*1000); err != nil {
t.Fatal(err)
}
ran, err := q.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if ran {
t.Error("a delayed job ran immediately")
}
}
func TestStats(t *testing.T) {
db := testDB(t)
q := New(db, discard())
q.Register("noop", func(context.Context, json.RawMessage) error { return nil })
for i := 0; i < 3; i++ {
if err := q.Enqueue(context.Background(), "noop", nil); err != nil {
t.Fatal(err)
}
}
if _, err := q.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
stats, err := q.Stats(context.Background())
if err != nil {
t.Fatal(err)
}
if stats[StateDone] != 1 || stats[StateQueued] != 2 {
t.Errorf("stats = %v, want done:1 queued:2", stats)
}
}