Files
yellowjacket/backend/jobs/store.go
yonluandClaude Opus 5 01bc5f2094 feat(jobs): surface background jobs with progress, logs and controls
Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:42:22 -04:00

124 lines
2.7 KiB
Go

package jobs
import (
"log/slog"
"time"
"yellowjacket/backend/database"
)
// Persisted is a job whose paused state outlived the process that
// created it. The owning subsystem adopts these back into the registry
// during startup, re-attaching the controls needed to resume.
type Persisted struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
}
// Store persists durable job state to the job_state table.
type Store struct {
db *database.DB
logger *slog.Logger
}
// NewStore creates a job state store backed by the application database.
func NewStore(db *database.DB, logger *slog.Logger) *Store {
return &Store{db: db, logger: logger}
}
// SetPaused records that a job is paused.
func (s *Store) SetPaused(p Persisted) {
if s == nil || s.db == nil {
return
}
if _, err := s.db.ExecContext(
`INSERT OR REPLACE INTO job_state`+
` (id, kind, title, subtitle, paused_at)`+
` VALUES (?, ?, ?, ?, ?)`,
p.ID, string(p.Kind), p.Title, p.Subtitle,
time.Now().UTC().Format(time.RFC3339),
); err != nil {
s.logger.Warn("jobs: could not persist paused job",
"id", p.ID, "err", err)
}
}
// ClearPaused removes a job's durable pause record.
func (s *Store) ClearPaused(id string) {
if s == nil || s.db == nil {
return
}
if _, err := s.db.ExecContext(
"DELETE FROM job_state WHERE id = ?", id,
); err != nil {
s.logger.Warn("jobs: could not clear paused job",
"id", id, "err", err)
}
}
// PausedEntries returns every persisted paused job of the given kind.
func (s *Store) PausedEntries(kind Kind) []Persisted {
if s == nil || s.db == nil {
return nil
}
rows, err := s.db.QueryContext(
`SELECT id, kind, title, subtitle FROM job_state`+
` WHERE kind = ? ORDER BY paused_at`,
string(kind),
)
if err != nil {
s.logger.Warn("jobs: could not read paused jobs",
"kind", kind, "err", err)
return nil
}
defer func() { _ = rows.Close() }()
var out []Persisted
for rows.Next() {
var (
p Persisted
kindText string
)
if scanErr := rows.Scan(
&p.ID, &kindText, &p.Title, &p.Subtitle,
); scanErr != nil {
s.logger.Warn("jobs: could not scan paused job", "err", scanErr)
continue
}
p.Kind = Kind(kindText)
out = append(out, p)
}
return out
}
// IsPaused reports whether the given job ID has a durable pause record.
// Subsystems check this before auto-starting work at launch.
func (s *Store) IsPaused(id string) bool {
if s == nil || s.db == nil {
return false
}
rows, err := s.db.QueryContext(
"SELECT 1 FROM job_state WHERE id = ?", id,
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}