Skip to content

fix(database): normalize virtual health paths - #836

Open
dclipca wants to merge 4 commits into
javi11:mainfrom
dclipca:fix/normalize-virtual-health-paths
Open

fix(database): normalize virtual health paths#836
dclipca wants to merge 4 commits into
javi11:mainfrom
dclipca:fix/normalize-virtual-health-paths

Conversation

@dclipca

@dclipca dclipca commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • canonicalize health and import-history virtual paths to forward-slash, root-relative form at every write/read boundary
  • add SQLite and PostgreSQL migration 035 to normalize legacy rows and conservatively merge file_health collisions without discarding repair evidence
  • keep dialect-specific SQL at the existing database boundary and add migration/repository regression coverage

Why

Legacy leading separators and Windows separators can make logically identical paths miss health/history joins or collide under the file_health unique key. Normalizing only new writes leaves existing rows inconsistent, so the migration and repository behavior land together.

This is separate from #667: that PR fixed failed-NZB filesystem namespacing; this change repairs persisted virtual-path identity.

Validation

  • go test ./internal/database
  • go vet ./internal/database/...
  • SQLite migration/repository regression tests
  • PostgreSQL migration smoke test
  • git diff --check

The migration is intentionally non-reversible because collision merging cannot be losslessly undone.

@javi11 javi11 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice win on the join change — dropping TRIM() from the join predicates makes them sargable so idx_file_health_path actually gets used. That part I like.

Migration 035 is the blocker though. It does DELETE FROM file_health + full reinsert, so it rewrites the whole table and all 8 indexes regardless of how many rows actually need fixing. I benchmarked it on SQLite/WAL, NVMe:

rows dirty 035
50k 0 1.6s
200k 0 9.1s
200k 1/1000 9.6s

Cost scales with library size, not with rows needing repair — a clean 200k table pays the same 9.1s. That's a fast dev machine; most people run AltMount on a NAS or Pi, so 500k–1M rows is minutes of silent hang at startup with no logging. Users will assume it's stuck and kill it mid-migration.

Doing only the dirty rows is 75ms at 200k (~120x), and the merge then runs over 200 rows instead of 200k:

UPDATE file_health SET file_path = trim(replace(file_path, char(92), '/'), '/')
WHERE file_path <> trim(replace(file_path, char(92), '/'), '/')
  AND NOT EXISTS (SELECT 1 FROM file_health c
                  WHERE c.file_path = trim(replace(file_health.file_path, char(92), '/'), '/'));

Whatever is still non-canonical after that is exactly the collision set — run the existing merge over just those.

Two more things on the migration: it materializes two full copies of the table in temp storage, and in Docker SQLite's temp store often lands on a small /tmp or a RAM-backed tmpfs, so a big library can hit database or disk is full or OOM the container. And on Postgres the delete+reinsert leaves 100% dead tuples — table and indexes stay at double size until autovacuum catches up, and you can't VACUUM inside the goose transaction.


Two things outside the diff that belong in this PR:

internal/database/health_repository.go:1996 (GetFilesByPaths) — this one got missed, still strings.TrimPrefix(path, "/"), the same idiom you replaced in DeleteHealthRecordsBulk just above. No backslash conversion, no trailing trim, no double-slash. Matters more now that the joins lost their TRIM() — that was what was covering for it. Caller is POST /health/regenerate-symlinks, which takes file_paths straight from the request, so \movies\x.mkv silently matches nothing and it reports 0 files.

health_repository.go:1731 — while you're on that line, the prefix is + "%" but every other prefix query in the file uses + "/%" (569, 605, 635). So tv/Show also matches tv/ShowOther/..., and this is a DELETE. Pre-existing, but TrimPrefixTrim now also strips a trailing separator a caller might have passed as the bound.

SET virtual_path = trim(replace(virtual_path, char(92), '/'), '/')
WHERE virtual_path <> trim(replace(virtual_path, char(92), '/'), '/');

DROP TABLE IF EXISTS file_health_path_normalized;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would drop a real table of that name if one ever existed. Doesn't buy anything either — temp tables die with the session.

WHEN 'degraded' THEN 4
WHEN 'checking' THEN 3
WHEN 'pending' THEN 2
WHEN 'partial' THEN 2

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code — partial was dropped from the CHECK constraint back in 004, so no row can hold it. Same on line 76. Reads like live logic.

Comment on lines +24 to +76
WITH ranked AS (
SELECT h.*,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE status
WHEN 'corrupted' THEN 6
WHEN 'repair_triggered' THEN 5
WHEN 'degraded' THEN 4
WHEN 'checking' THEN 3
WHEN 'pending' THEN 2
WHEN 'partial' THEN 2
WHEN 'healthy' THEN 1
ELSE 0
END DESC, updated_at DESC, id DESC) AS status_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(library_path, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS library_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(last_error, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS last_error_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(source_nzb_path, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS source_nzb_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(error_details, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS error_details_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN release_date IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS release_date_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(metadata, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS metadata_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(indexer, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS indexer_rank,
ROW_NUMBER() OVER (
PARTITION BY canonical_path
ORDER BY CASE WHEN NULLIF(download_id, '') IS NOT NULL THEN 0 ELSE 1 END,
updated_at DESC, id DESC) AS download_id_rank
FROM file_health_path_normalized h
)
SELECT
COALESCE(MIN(CASE WHEN file_path = canonical_path THEN id END), MIN(id)) AS id,
canonical_path AS file_path,
MAX(CASE WHEN library_rank = 1 AND NULLIF(library_path, '') IS NOT NULL THEN library_path END) AS library_path,
MAX(CASE WHEN status_rank = 1 THEN CASE status WHEN 'partial' THEN 'corrupted' ELSE status END END) AS status,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge is column-wise across 9 independent rank windows, so the survivor is a composite that existed in no prior state — status from one row, counters from another, timestamps from a third. MAX(retry_count) in particular trips the repair gate at worker.go:523 a cycle early.

Not strictly wrong, "worst-of wins" is a fair reading of not discarding evidence, and it only hits real collisions. But picking one winner row and backfilling only its NULL/empty columns would be easier to reason about. At minimum worth a comment saying this is deliberate.

@dclipca

dclipca commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. The branch is now at 26faf6d (commits c93f9d3, 98674b3, bd4165a, 26faf6d) and addresses the blocker:

  • Migration 035 normalizes import_history in place and rebuilds only dirty rows plus canonical collision groups; a clean file_health catalog keeps its IDs/values and avoids the full-table DELETE/INSERT.
  • Composite merge semantics are explicit and covered: retain the canonical ID (or lowest ID), choose the most severe/newest status, preserve newest non-empty evidence, max counters/priority, min created/scheduled times, latest updated time, and OR the mask flag. The missed GetFilesByPaths normalization and directory-prefix escaping are included.
  • Temp cleanup is explicitly scoped to SQLite temp / PostgreSQL pg_temp. Regression coverage creates permanent tables with the same names and verifies their markers survive.
  • SQLite 200k-row smoke on the migration SQL measured about 0.32s clean and 0.30s with 200 dirty rows (WAL/temp-memory test setup); the existing 200k full-rebuild baseline was 9.1s clean / 9.6s at 1/1000 dirty.
  • PostgreSQL 16 smoke passed with the same-name permanent tables: canonical health/history invariants held and all permanent markers survived.
  • GitHub checks are green at this tip: test and build-docs.

The remaining action is maintainer review/merge; I’m not claiming merge authority.

@javi11

javi11 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Nice work on this — the affected-table design plus the index on file_health_path_collisions(canonical_path) genuinely pays off, and using DELETE+INSERT to sidestep the update_file_health_timestamp trigger so updated_at survives is a nice touch. Two things I'd like resolved before merge.

1. Removing the TRIM tolerance can delete valid library symlinks

health_repository.go:2081 and repository.go:1097,1121,1144-1145 drop TRIM(x,'/') in favour of exact equality. Correctness now depends entirely on migration 035 having actually run.

HasImportHistoryForPath is the guard at internal/health/library_sync.go:923-929:

hasHistory, checkErr := lsw.healthRepo.HasImportHistoryForPath(ctx, mountRelPath)
if checkErr == nil && hasHistory {
    slog.InfoContext(ctx, "Skipping orphaned symlink deletion: import history exists for this file", ...)
    continue
}

A false result deletes the symlink as orphaned. If 035 doesn't apply, every legacy import_history row with a leading / or a backslash stops matching, and library symlinks for valid imports get deleted. Before this PR, TRIM made that case self-healing at runtime.

That isn't hypothetical in this codebase. db.go:167 inserts a goose_db_version row without running the migration body:

db.Exec("INSERT INTO goose_db_version (version_id, is_applied, tstamp) VALUES (27, 1, CURRENT_TIMESTAMP)")

and db.go:289-292 exists precisely because "goose migration 034 fails to apply due to a version conflict from dev-branch history." So a skipped migration is a known, handled state here — and this PR turns that state into data loss rather than a stale-but-recoverable path.

Either fix is cheap:

  • Keep the tolerant predicate on the read path (WHERE virtual_path = ? OR TRIM(virtual_path,'/') = ?) while writes go canonical, or
  • Follow the repo's own convention and add the idempotent UPDATE import_history SET virtual_path = ... WHERE virtual_path <> ... to ensureSchemaIntegrity. It's already WHERE-guarded, so it costs nothing on a clean DB.

2. Migration cost on a dirty catalog is ~4x higher than it needs to be

I benchmarked the actual migration SQL against a synthetic v34 schema with all five real indexes and the trigger in place:

Scenario 500k rows 1M rows
Clean catalog (no dirty paths) 0.79s
Every path dirty (legacy leading /) 21.5s 44.1s
Every path dirty (Windows backslashes) 21.4s
500k rows, 250k collision pairs 16.5s

Scaling is linear (2x rows -> 2.05x time). The clean fast path is excellent and matches your 100k/61ms test.

The gap is that the clean case is the only one benchmarked, and a legacy leading-slash catalog is exactly the population this migration exists for — so "every row dirty" is the expected case for affected users, not a pathological one. That's 21.5s at 500k and 44s at 1M of blocked startup, since goose.Up runs before the app serves.

Where the time goes at 500k all-dirty:

Stage Time Share
file_health_path_collisions GROUP BY scan 0.15-0.6s 3%
file_health_path_affected build 0.33-0.54s 2%
file_health_path_merged (9 window functions) 10.6s 49%
DELETE FROM file_health 3.0s 14%
INSERT (rebuilds 5 indexes) 7.9s 37%

The merge CTE dominates: SQLite computes nine separate ROW_NUMBER() partitions, each needing its own sort of the affected set. And for a non-colliding dirty row the group size is 1, so all nine ranks are trivially 1 — the migration pays nine sorts plus a DELETE+INSERT with full index rebuild for rows that need nothing but a file_path rewrite.

Suggested split

A non-colliding dirty row has a unique canonical target, so a plain in-place UPDATE is safe against UNIQUE(file_path). Reserve the window-function merge for actual collision groups, which are normally a handful of rows rather than the whole catalog:

-- collisions temp table + index: unchanged

DROP TRIGGER update_file_health_timestamp;

UPDATE file_health
SET file_path = trim(replace(file_path, char(92), '/'), '/')
WHERE file_path <> trim(replace(file_path, char(92), '/'), '/')
  AND NOT EXISTS (
      SELECT 1 FROM file_health_path_collisions c
      WHERE c.canonical_path = trim(replace(file_health.file_path, char(92), '/'), '/'));

CREATE TRIGGER update_file_health_timestamp
AFTER UPDATE ON file_health
BEGIN
    UPDATE file_health SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

-- then run the existing affected/merged/DELETE/INSERT path, but scoped to
-- collision groups only

Measured on the same 500k all-dirty dataset:

Approach Time
Current (DELETE + INSERT for all affected) 21.5s
UPDATE for non-colliding + merge only collision groups 5.5s

~4x faster and it skips the index rebuild entirely. The trigger drop/recreate is what preserves updated_at — I confirmed it stays at 2026-08-22 10:00:00 across the run, so you keep the property that motivated DELETE+INSERT in the first place.

Two smaller notes on cost: the temp tables materialize full row copies of the affected set, so at 1M dirty rows that's roughly two extra copies of the table's row data spilling to SQLite's temp file — worth a thought on containers with a small /tmp. And the PostgreSQL cost is entirely unmeasured; a whole-table DELETE+INSERT there also leaves table and index bloat pending autovacuum on top of the same window-function work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants