17 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-backend-correctness | 02 | execute | 1 |
|
true |
|
|
Purpose: The backend currently swallows artist credit errors entirely and mixes non-fatal scan issues with catastrophic failures in a single error return. After this plan, callers can distinguish "scan completed with issues" from "scan failed."
Output: New backend/database/errors.go, updated migration in database.go, enhanced ScanMetrics with warnings, reclassified error paths throughout Scan().
<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-backend-correctness/02-CONTEXT.md @.planning/phases/02-backend-correctness/02-RESEARCH.md@backend/database/database.go @backend/library/metrics.go @backend/library/library.go @backend/library/rescan.go
From backend/database/database.go:
type DB struct {
db *sql.DB
Ctx context.Context
Queries *sqlcgen.Queries
logger *slog.Logger
}
// Migration pattern — runMigrations at line 156:
// Checks PRAGMA user_version, runs migrations conditionally.
// Latest migration is 2 (migration2BasenameAndFTS).
// Migration 3 should follow the same pattern at end of runMigrations().
func runMigrations(ctx context.Context, db *sql.DB, logger *slog.Logger) error
From backend/library/metrics.go:
type ScanMetrics struct {
mu sync.Mutex
// ... timing/count fields ...
Added int64 `json:"added"`
Updated int64 `json:"updated"`
Skipped int64 `json:"skipped"`
Removed int64 `json:"removed"`
}
// Existing mutex-protected method pattern:
func (m *ScanMetrics) addExtraction(fileType string, tagTime, durationTime time.Duration)
From backend/library/library.go:
func (l *Library) Scan() (*ScanMetrics, error) // line 175
func (l *Library) commitBatch(batch []importResult, ...) error // line 652
func (l *Library) saveAudioFile(q *sqlcgen.Queries, tx *sql.Tx, ...) error // line 713
func (l *Library) updateAudioFileMetadata(q *sqlcgen.Queries, tx *sql.Tx, ...) error // line 809
func (l *Library) cachedLinkArtist(q *sqlcgen.Queries, cache *entityCache, name string, creditID int64) // line 1074
// Current error accumulation pattern in Scan():
var scanErr error
var errMu sync.Mutex
// Various error paths use: scanErr = errors.Join(scanErr, err)
From backend/library/library.go — cachedLinkArtist (line 1074-1108):
func (l *Library) cachedLinkArtist(
q *sqlcgen.Queries,
cache *entityCache,
name string,
creditID int64,
) {
// ... artist upsert ...
_, _ = q.CreateArtistCreditArtist(l.ctx, ...) // <-- discards BOTH returns
cache.linkedCredits[linkKey] = struct{}{}
}
From backend/library/rescan.go — handleConfigUpdate calls Scan:
func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
if _, err := l.Scan(); err != nil { // <-- only checks error return
updateErr = errors.Join(updateErr, ...)
}
}
SQLite driver types (from modernc.org/sqlite):
// modernc.org/sqlite — Error type
type Error struct { ... }
func (e *Error) Code() int // returns extended result code
// modernc.org/sqlite/lib — Constants
const SQLITE_CONSTRAINT_UNIQUE = 2067
Create backend/database/errors.go with:
package database
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueViolation reports whether err is a SQLite UNIQUE
// constraint violation (extended result code 2067).
func IsUniqueViolation(err error) bool {
var sqliteErr *sqlite.Error
if errors.As(err, &sqliteErr) {
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}
return false
}
CORR-08 Part 2 — Migration 3 (backend/database/database.go):
Add migration 3 at the end of runMigrations(), after the if version < 2 block (after line 221) and before the final return nil:
// Migration 3: add UNIQUE constraint to artist_credit_artist.
if version < 3 {
logger.Info(
"applying migration 3: artist_credit_artist unique constraint",
)
// Remove duplicates first (keep lowest ID per pair).
if _, err := db.ExecContext(ctx, `
DELETE FROM artist_credit_artist
WHERE id NOT IN (
SELECT MIN(id)
FROM artist_credit_artist
GROUP BY artist_id, credit_id
)
`); err != nil {
return fmt.Errorf(
"migration 3: could not deduplicate: %w", err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS
idx_artist_credit_artist_unique
ON artist_credit_artist(artist_id, credit_id)
`); err != nil {
return fmt.Errorf(
"migration 3: could not create unique index: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 3",
); err != nil {
return fmt.Errorf(
"could not set user_version to 3: %w", err,
)
}
logger.Info("migration 3 complete")
}
Ensure fmt is imported in database.go (it already is — verify).
cd /mnt/vault/dev/golang/yellowjacket && go vet ./backend/database/... && go build ./backend/database/... && grep -q "IsUniqueViolation" backend/database/errors.go && grep -q "version < 3" backend/database/database.go && grep -q "idx_artist_credit_artist_unique" backend/database/database.go
IsUniqueViolation exported function exists in backend/database/errors.go; migration 3 deduplicates existing rows and creates UNIQUE index on artist_credit_artist(artist_id, credit_id); database package compiles cleanly
- Add
ScanWarningstruct andWarningsfield toScanMetrics:
// ScanWarning represents a non-fatal issue encountered during scanning.
type ScanWarning struct {
FilePath string `json:"filePath"`
Phase string `json:"phase"`
Err error `json:"err"`
}
-
Add
Warnings []ScanWarningfield toScanMetricsstruct (after the file count fields, before the closing brace). Add JSON tag:json:"warnings". -
Add
addWarningmethod:
// addWarning records a non-fatal scan issue. Safe for concurrent use.
func (m *ScanMetrics) addWarning(filePath, phase string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.Warnings = append(m.Warnings, ScanWarning{
FilePath: filePath,
Phase: phase,
Err: err,
})
}
CORR-09 Part 2 — Reclassify error paths in Scan() (backend/library/library.go):
The key rule: transaction begin/commit failures and context cancellation are ALWAYS fatal. Individual file operations (save, FTS index, orphan delete, walk errors, variant generation) are ALWAYS warnings.
Changes to Scan():
-
WalkDir errors (lines 319-328): Replace
scanErr = errors.Join(scanErr, ...)withmetrics.addWarning("", "walk", walkErr). Walk errors are non-fatal — the scan already processed files discovered before the error. -
Metadata extraction failures (lines 436-438): Replace the
errMu.Lock(); scanErr = errors.Join(scanErr, err); errMu.Unlock()block withmetrics.addWarning(work.absolutePath, "extraction", err). TheerrMulock is no longer needed for this path (addWarning has its own mutex). -
commitBatch errors (lines 388-390): This requires splitting. The
commitBatchfunction currently returns both transaction failures and individual file save failures as a single error.- Modify
commitBatchto acceptmetrics *ScanMetrics(it already does — line 655) and callmetrics.addWarningfor individual file save failures instead of accumulating intobatchErr. - The
batchErrvariable incommitBatchis eliminated. IndividualsaveErrvalues go tometrics.addWarning(result.absolutePath, "commit", saveErr). - Only the
tx.Commit()failure (line 702-706) remains as a returned error — this is a fatal transaction failure. - In
Scan(), the caller at lines 383-391 still checksbatchErr— sincecommitBatchnow only returns fatal commit errors, rename the check to reflect this: if commitBatch returns an error, it's fatal. Return immediately from the DB writer goroutine with the fatal error set viaerrMu.
- Modify
-
Orphan delete failures (lines 484-495): Already logged but silently continued. Add
metrics.addWarning(path, "orphan", err)alongside the existing log. Thereturn true(continue iteration) stays. -
Orphan FTS delete failures (lines 498-505): Already logged but silently continued. Add
metrics.addWarning(path, "orphan", err)alongside the existing log. -
Variant generation failure (lines 518-522): Already logged. Add
metrics.addWarning("", "variant", err)alongside the existing log. -
FTS indexing failures in saveAudioFile (lines 787-798) and updateAudioFileMetadata (lines 866-893): These are currently logged but don't return errors. Convert to warnings: add
metrics.addWarning(result.absolutePath, "commit", err)alongside the existing log. SincesaveAudioFileandupdateAudioFileMetadataalready receivemetrics, this is straightforward. -
Remove
errMuandscanErraccumulation pattern. After reclassification:scanErrshould only contain fatal errors (context cancellation, transaction commit failures)errMumay still be needed if the DB writer goroutine sets a fatal error that Scan() reads. KeeperrMubut only use it for fatal error paths.- The extraction worker pool no longer writes to
scanErr— all extraction failures are warnings.
CORR-08 Part 3 — Update cachedLinkArtist (backend/library/library.go):
Per CONTEXT.md decision: pass metrics *ScanMetrics as an additional parameter. Per research recommendation: call metrics.addWarning() directly for non-UNIQUE errors.
- Change
cachedLinkArtistsignature to:
func (l *Library) cachedLinkArtist(
q *sqlcgen.Queries,
cache *entityCache,
metrics *ScanMetrics,
name string,
creditID int64,
)
- Replace the
_, _ = q.CreateArtistCreditArtist(...)at line 1101 with:
_, err = q.CreateArtistCreditArtist(
l.ctx,
sqlcgen.CreateArtistCreditArtistParams{
ArtistID: artist.ID,
CreditID: creditID,
},
)
if err != nil {
if !database.IsUniqueViolation(err) {
l.logger.Warn(
"could not link artist to credit",
"artist", name,
"creditID", creditID,
"err", err,
)
metrics.addWarning(
name, "commit",
fmt.Errorf(
"artist-credit link failed for %q: %w",
name, err,
),
)
}
// UNIQUE violation: link already exists in DB, not an error.
}
-
Add
"yellowjacket/backend/database"to the imports inlibrary.goif not already present. -
Update ALL callers of
cachedLinkArtist(inprocessMetadata) to passmetricsas the new parameter. Search forl.cachedLinkArtist(and add the metrics argument.
CORR-09 Part 3 — Update handleConfigUpdate caller (backend/library/library.go):
In handleConfigUpdate (line 1325), after calling l.Scan(), log any warnings from the returned metrics:
if metrics, err := l.Scan(); err != nil {
updateErr = errors.Join(updateErr, fmt.Errorf(
"problem scanning library on config update: %w", err,
))
} else if len(metrics.Warnings) > 0 {
l.logger.Warn(
"library scan completed with warnings",
"warningCount", len(metrics.Warnings),
)
}
Note: change the _ discard of metrics to capture it.
cd /mnt/vault/dev/golang/yellowjacket && go vet ./backend/... && go build ./backend/... && grep -q "ScanWarning" backend/library/metrics.go && grep -q "addWarning" backend/library/metrics.go && grep -q "IsUniqueViolation" backend/library/library.go && grep -q "metrics.addWarning" backend/library/library.go && grep -c "metrics.addWarning" backend/library/library.go | grep -qE '^[5-9]|^[1-9][0-9]'
ScanWarning struct exists with FilePath/Phase/Err fields; addWarning is mutex-protected; Scan() returns only fatal errors in error return; all non-fatal errors (extraction, FTS, orphan, walk, variant, individual file save) go to ScanMetrics.Warnings; cachedLinkArtist checks errors with IsUniqueViolation and records non-UNIQUE failures as warnings; handleConfigUpdate logs warning count
All backend packages pass vet
go vet ./backend/...
Linting passes
golangci-lint run ./backend/...
IsUniqueViolation helper exists
grep -q "func IsUniqueViolation" backend/database/errors.go
Migration 3 exists
grep -q "version < 3" backend/database/database.go grep -q "idx_artist_credit_artist_unique" backend/database/database.go
ScanWarning type and addWarning method exist
grep -q "type ScanWarning struct" backend/library/metrics.go grep -q "func (m *ScanMetrics) addWarning" backend/library/metrics.go
cachedLinkArtist uses IsUniqueViolation
grep -q "database.IsUniqueViolation" backend/library/library.go
No discarded CreateArtistCreditArtist returns
! grep -q '_, _ = q.CreateArtistCreditArtist' backend/library/library.go
Warnings are collected (multiple addWarning calls)
test "$(grep -c 'metrics.addWarning' backend/library/library.go)" -ge 5
scanErr only used for fatal errors (should be minimal occurrences)
handleConfigUpdate captures metrics
grep -q 'metrics.Warnings' backend/library/library.go
Race detector passes
go test -race -count=1 ./backend/database/... ./backend/library/...
</verification>
<success_criteria>
- `go build ./backend/...` compiles cleanly
- `go vet ./backend/...` passes
- `golangci-lint run ./backend/...` passes
- `go test -race ./backend/database/... ./backend/library/...` passes
- `IsUniqueViolation` helper correctly detects UNIQUE constraint violations
- Migration 3 deduplicates and adds UNIQUE index
- `ScanWarning` struct exists with `FilePath`, `Phase`, `Err` fields
- `addWarning` is mutex-protected for concurrent use
- `Scan()` error return only contains fatal errors
- All non-fatal scan errors are accumulated in `ScanMetrics.Warnings`
- `cachedLinkArtist` checks errors and only ignores UNIQUE violations
- `handleConfigUpdate` logs warning count after scan
</success_criteria>
<output>
After completion, create `.planning/phases/02-backend-correctness/02-02-SUMMARY.md`
</output>