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>
This commit is contained in:
2026-07-25 14:42:22 -04:00
co-authored by Claude Opus 5
parent aead8eaef4
commit 01bc5f2094
48 changed files with 6656 additions and 2271 deletions
+47
View File
@@ -14,6 +14,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// Service is the Wails-bound service for the explore feature.
@@ -148,6 +149,52 @@ func (e *Service) StopIndexBuild() {
e.index.StopBuild()
}
// SetJobRegistry wires the background job registry into the search
// index so its build reports progress and controls to the frontend.
func (e *Service) SetJobRegistry(reg *jobs.Registry) {
e.index.SetJobRegistry(reg)
}
// AdoptPausedIndexBuild re-registers a build paused in a previous
// session so it appears in the jobs panel, still paused.
func (e *Service) AdoptPausedIndexBuild() {
e.index.AdoptPausedBuild()
}
// IndexImportComplete reports whether the dump import has finished all
// of its stages. Distinct from IsIndexReady, which only means the index
// holds enough rows to answer queries — a partially imported index is
// ready but not complete. Used by the headless builder to decide
// whether another run is needed.
func (e *Service) IndexImportComplete() bool {
return e.index.ImportComplete()
}
// IndexBaselineSeries returns the incremental listens series the index's
// popularity is caught up to. A change across a refresh means new data
// was folded in.
func (e *Service) IndexBaselineSeries() int {
return e.index.BaselineSeries()
}
// IndexLastImported returns when the dump import last completed, or the
// zero time if it never has.
func (e *Service) IndexLastImported() time.Time {
return e.index.LastImported()
}
// PrepareIndexRebuild clears the completion marker so the next build
// re-imports from the newest published dump.
func (e *Service) PrepareIndexRebuild() {
e.index.PrepareRebuild()
}
// RefreshIndexNow folds newly published incremental listens dumps into
// the index synchronously. Pass 0 to bypass the cadence gate.
func (e *Service) RefreshIndexNow(minInterval time.Duration) {
e.index.RefreshNow(e.ctx, minInterval)
}
// IsIndexReady returns true once the index has been populated.
func (e *Service) IsIndexReady() bool {
return e.index.IsReady()
+380
View File
@@ -0,0 +1,380 @@
package explore
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"yellowjacket/backend/jobs"
)
// errIndexStageFailed wraps the per-stage error text reported by the
// dump importer so the job carries a typed failure.
var errIndexStageFailed = errors.New("index build stage failed")
// indexJobID is the stable registry ID for the search index build.
// There is only ever one, so the ID is a constant.
const indexJobID = "index:build"
// indexPausedKey marks a build the user paused. It lives in
// explore_index_meta alongside the import's other checkpoints so a
// paused build stays paused across a restart instead of resuming on
// the next launch.
const indexPausedKey = "index_build_paused"
// SetJobRegistry wires the background job registry so index builds
// report progress, stage state, logs, and pause/cancel controls.
func (si *SearchIndex) SetJobRegistry(reg *jobs.Registry) {
si.mu.Lock()
si.jobs = reg
si.mu.Unlock()
}
// jobRegistry returns the registry, or nil when none is wired.
func (si *SearchIndex) jobRegistry() *jobs.Registry {
si.mu.RLock()
defer si.mu.RUnlock()
return si.jobs
}
// logIndexJob appends a line to the index build's job log, if a build
// job is currently registered.
func (si *SearchIndex) logIndexJob(level jobs.Level, message string) {
reg := si.jobRegistry()
if reg == nil {
return
}
if h := reg.Get(indexJobID); h != nil {
h.Logf(level, message)
}
}
// indexJobSpec builds the registry spec for the index build.
//
// The build is genuinely pausable rather than merely cancellable: the
// dump importer checkpoints its listen-count offset to counts.bin and
// its stage to state.json, so stopping and restarting picks up where it
// left off instead of re-downloading multiple gigabytes.
func (si *SearchIndex) indexJobSpec(state jobs.State) jobs.Spec {
return jobs.Spec{
ID: indexJobID,
Kind: jobs.KindIndexBuild,
Title: "Building search index",
Subtitle: "MusicBrainz catalog + ListenBrainz popularity",
State: state,
Caps: jobs.Caps{
Pausable: true,
Cancellable: true,
},
Durable: true,
Controls: jobs.Controls{
Pause: si.PauseBuild,
Resume: si.ResumeBuild,
Cancel: si.CancelBuild,
},
}
}
// syncIndexJob mirrors an IndexStatus snapshot into the job registry.
// It is driven from emitStatus, which every status mutation already
// funnels through, so there is no path that updates one view and not
// the other.
func (si *SearchIndex) syncIndexJob(status IndexStatus) {
reg := si.jobRegistry()
if reg == nil {
return
}
si.mu.RLock()
paused := si.buildPaused
si.mu.RUnlock()
h := reg.Get(indexJobID)
// A build with no stages is the early-return path in runDumpBuild
// (the catalog import is already done). Nothing to show.
if h == nil {
if !status.Building || len(status.Tiers) == 0 {
return
}
h = reg.Start(si.indexJobSpec(jobs.StateRunning))
h.Logf(jobs.LevelInfo, "Index build started")
}
// A finished job is immutable. Without this guard the 3-second
// status ticker would keep touching it forever, re-emitting
// JobsChanged long after the build ended.
if h.State().IsTerminal() {
return
}
si.applyStagesToJob(h, status)
if status.Building {
// Don't stomp a pause or cancel that is still settling; those
// transitions are confirmed by their own control paths.
if h.State() == jobs.StateQueued {
h.SetState(jobs.StateRunning)
}
return
}
si.finishIndexJob(h, paused)
}
// applyStagesToJob maps index tiers onto job stages and derives the
// headline progress bar from whichever tier is currently running.
func (si *SearchIndex) applyStagesToJob(h *jobs.Handle, status IndexStatus) {
stages := make([]jobs.Stage, 0, len(status.Tiers))
var (
phase string
current, total int64
foundRunningTier bool
)
for _, t := range status.Tiers {
stages = append(stages, jobs.Stage{
Name: t.Name,
State: t.State,
Current: int64(t.Completed),
Total: int64(t.Total),
Error: t.Error,
})
if t.State == "running" && !foundRunningTier {
foundRunningTier = true
phase = t.Name
current = int64(t.Completed)
total = int64(t.Total)
}
}
h.SetStages(stages)
if foundRunningTier {
h.SetPhase(phase)
h.SetProgress(current, total)
}
h.SetStats([]jobs.Stat{
{Label: "Artists", Value: strconv.Itoa(status.Artists)},
{Label: "Release groups", Value: strconv.Itoa(status.ReleaseGroups)},
{Label: "Recordings", Value: strconv.Itoa(status.Recordings)},
{Label: "Total rows", Value: strconv.Itoa(status.TotalRows)},
})
}
// finishIndexJob resolves a build that is no longer running into the
// right terminal (or paused) state. A stopped build that never wrote
// the done marker is reported as cancelled rather than complete —
// claiming success for a half-finished import would be a lie.
func (si *SearchIndex) finishIndexJob(h *jobs.Handle, paused bool) {
if h.State().IsTerminal() || h.State() == jobs.StatePaused {
return
}
// A stage that errored means the build failed; reporting that as
// "stopped" would hide a real failure behind a neutral word.
if !paused {
for _, stage := range h.Snapshot().Stages {
if stage.State == "error" {
h.Fail(fmt.Errorf("%w: %s: %s",
errIndexStageFailed, stage.Name, stage.Error))
return
}
}
}
if paused {
h.SetPhase("Paused")
h.SetState(jobs.StatePaused)
h.Logf(jobs.LevelInfo,
"Build paused — progress is checkpointed and will resume "+
"from here")
return
}
if si.hasMeta(dumpImportDoneKey) {
h.Logf(jobs.LevelInfo, "Index build complete")
h.Complete()
return
}
h.Logf(jobs.LevelInfo, "Build stopped before finishing")
h.Cancelled()
}
// PauseBuild stops the in-flight index build and remembers that the
// user asked for it, so it is not restarted automatically — including
// on the next launch. Blocks until the build goroutine exits; the job
// registry invokes controls on their own goroutine.
func (si *SearchIndex) PauseBuild() {
si.mu.Lock()
si.buildPaused = true
si.mu.Unlock()
si.setMeta(indexPausedKey, "1")
si.StopBuild()
si.emitStatus()
}
// ResumeBuild clears the pause and restarts the build, which picks up
// from the importer's last checkpoint.
func (si *SearchIndex) ResumeBuild() {
si.mu.Lock()
si.buildPaused = false
ctx := si.runtimeCtx
si.mu.Unlock()
si.deleteMeta(indexPausedKey)
if reg := si.jobRegistry(); reg != nil {
if h := reg.Get(indexJobID); h != nil {
h.SetState(jobs.StateRunning)
h.Logf(jobs.LevelInfo, "Resuming from last checkpoint")
}
}
if ctx != nil {
si.StartBuild(ctx)
}
}
// CancelBuild stops the build without marking it paused. The importer's
// on-disk checkpoints are left in place, so starting a new build later
// still resumes rather than re-downloading — cancel here means "stop
// working now", not "throw away the progress".
func (si *SearchIndex) CancelBuild() {
si.mu.Lock()
si.buildPaused = false
si.mu.Unlock()
si.deleteMeta(indexPausedKey)
si.StopBuild()
si.emitStatus()
}
// ImportComplete reports whether the dump import wrote its done marker,
// meaning every stage finished. A resumable import that was interrupted
// leaves this false even though the index may already be queryable.
func (si *SearchIndex) ImportComplete() bool {
return si.hasMeta(dumpImportDoneKey)
}
// BaselineSeries returns the incremental listens series the index's
// popularity numbers are currently caught up to, or 0 when no baseline
// import has completed. A change in this value between two runs is the
// signal that a refresh actually folded in new data.
func (si *SearchIndex) BaselineSeries() int {
series, _ := si.metaInt(listensAppliedSeriesKey)
return series
}
// LastImported returns when the dump import last completed, or the zero
// time if it never has. Drives the rebuild cadence.
func (si *SearchIndex) LastImported() time.Time {
rows, err := si.db.QueryContext(
"SELECT value FROM explore_index_meta WHERE key = ?", dumpImportDoneKey,
)
if err != nil {
return time.Time{}
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return time.Time{}
}
var raw string
if err := rows.Scan(&raw); err != nil {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return parsed
}
// PrepareRebuild clears the completion marker so the next StartBuild
// re-imports from the newest published dump instead of short-circuiting.
//
// The importer deletes its staging directory on completion, so there is
// no stale checkpoint to clear as well — a rebuild rediscovers the
// current dump and starts from offset zero. Existing rows are left in
// place: assembly upserts by MBID, so the index stays queryable
// throughout rather than going empty for the length of a re-import.
func (si *SearchIndex) PrepareRebuild() {
si.deleteMeta(dumpImportDoneKey)
si.logger.Info("search index: cleared completion marker for rebuild")
}
// RefreshNow folds any newly published incremental listens dumps into
// the index's popularity numbers, synchronously. Pass 0 to bypass the
// cadence gate.
func (si *SearchIndex) RefreshNow(ctx context.Context, minInterval time.Duration) {
si.RefreshListenCounts(ctx, minInterval)
}
// buildPausedByUser reports whether a build was paused and not resumed,
// including by a previous session.
func (si *SearchIndex) buildPausedByUser() bool {
si.mu.RLock()
paused := si.buildPaused
si.mu.RUnlock()
if paused {
return true
}
return si.hasMeta(indexPausedKey)
}
// AdoptPausedBuild re-registers a build that was paused when the app
// last shut down, so it shows up in the jobs panel with a resume button
// instead of silently not running. Called during startup.
func (si *SearchIndex) AdoptPausedBuild() {
reg := si.jobRegistry()
if reg == nil {
return
}
// A completed import cannot be meaningfully paused; clear a stale
// marker rather than showing a job that would never do anything.
if si.hasMeta(dumpImportDoneKey) {
si.deleteMeta(indexPausedKey)
reg.Remove(indexJobID)
return
}
if !si.hasMeta(indexPausedKey) {
return
}
si.mu.Lock()
si.buildPaused = true
si.mu.Unlock()
h := reg.Start(si.indexJobSpec(jobs.StatePaused))
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo,
"Paused in a previous session — resume to continue from the "+
"last checkpoint")
si.logger.Info("search index: restored paused build")
}
+28
View File
@@ -17,6 +17,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// Index build parameters.
@@ -217,6 +218,12 @@ type SearchIndex struct {
// Build status tracking — read by GetIndexStatus for the UI.
buildStatus IndexStatus
// jobs is the background job registry; buildPaused records that the
// user paused the build, distinguishing a deliberate stop from a
// build that merely finished. Both are protected by mu.
jobs *jobs.Registry
buildPaused bool
}
// prefixCacheEntry is one memoised generic-query result.
@@ -495,6 +502,15 @@ func (si *SearchIndex) PersistSimilarArtists(sourceMBID string, similar []LBSimi
// StartBuild launches the background index build goroutine.
// Returns immediately.
func (si *SearchIndex) StartBuild(ctx context.Context) {
// A build the user paused stays paused until they resume it —
// including across restarts, where the marker is read back from
// explore_index_meta. ResumeBuild clears it before calling here.
if si.buildPausedByUser() {
si.logger.Info("search index: build is paused, not starting")
return
}
si.mu.Lock()
// Don't start if already running.
if si.cancel != nil {
@@ -639,10 +655,16 @@ func (si *SearchIndex) setTierStatus(name, state string, total, completed int) {
for i := range si.buildStatus.Tiers {
if si.buildStatus.Tiers[i].Name == name {
transitioned := si.buildStatus.Tiers[i].State != state
si.buildStatus.Tiers[i].State = state
si.buildStatus.Tiers[i].Total = total
si.buildStatus.Tiers[i].Completed = completed
si.mu.Unlock()
if transitioned {
si.logIndexJob(jobs.LevelInfo, "Stage "+state+": "+name)
}
si.emitStatus()
return
@@ -669,6 +691,8 @@ func (si *SearchIndex) setTierError(name, errMsg string) {
si.buildStatus.Tiers[i].State = "error"
si.buildStatus.Tiers[i].Error = errMsg
si.mu.Unlock()
si.logIndexJob(jobs.LevelError, name+": "+errMsg)
si.emitStatus()
return
@@ -691,6 +715,10 @@ func (si *SearchIndex) emitStatus() {
si.mu.RUnlock()
runtime.EventsEmit(si.runtimeCtx, events.IndexStatusChanged, status)
// Mirror into the shared job registry. Every status mutation goes
// through emitStatus, so hooking here covers all update paths.
si.syncIndexJob(status)
}
// GetPopularity returns the cached popularity (listen count) for