Migrate scan store to shared entities
This commit is contained in:
@@ -2,6 +2,7 @@ import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tgvideo_check.cli import app
|
||||
@@ -642,6 +643,168 @@ def test_scan_resume_does_not_reuse_previous_stage2_results(tmp_path, monkeypatc
|
||||
assert probe_calls == 1
|
||||
|
||||
|
||||
def test_scan_compacts_covered_historical_run_when_parent_root_is_scanned(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
parent = tmp_path / "a"
|
||||
nested = parent / "b"
|
||||
sibling = parent / "c"
|
||||
nested.mkdir(parents=True)
|
||||
sibling.mkdir()
|
||||
legacy_video = nested / "legacy.mp4"
|
||||
fresh_video = sibling / "fresh.mp4"
|
||||
legacy_video.write_bytes(b"legacy")
|
||||
fresh_video.write_bytes(b"fresh")
|
||||
db_path = tmp_path / "scan_state.db"
|
||||
|
||||
store = ScanStore(db_path)
|
||||
store.initialize()
|
||||
legacy_run = store.create_run(profile="tg-hdd", root_paths=(nested,))
|
||||
legacy_candidate = FileCandidate(
|
||||
path=legacy_video,
|
||||
size=legacy_video.stat().st_size,
|
||||
mtime_ns=legacy_video.stat().st_mtime_ns,
|
||||
extension="mp4",
|
||||
discovery_order=0,
|
||||
)
|
||||
[legacy_stored] = store.upsert_files(legacy_run, [legacy_candidate])
|
||||
store.record_stage1_result(
|
||||
legacy_stored.file_id,
|
||||
exit_code=0,
|
||||
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"},"streams":[{"codec_type":"video"}]}',
|
||||
stderr_text="",
|
||||
elapsed_ms=1,
|
||||
)
|
||||
store.record_stage1_status(
|
||||
legacy_stored.file_id,
|
||||
stage1_status="passed_stage1",
|
||||
final_status="passed_stage1",
|
||||
reason_code="passed_stage1",
|
||||
)
|
||||
store.update_run_status(legacy_run, status="completed")
|
||||
|
||||
probe_calls: list[Path] = []
|
||||
|
||||
def fake_ffprobe(path: Path) -> ProbeExecutionResult:
|
||||
probe_calls.append(path)
|
||||
return ProbeExecutionResult(
|
||||
status="completed",
|
||||
command=("ffprobe", str(path)),
|
||||
exit_code=0,
|
||||
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"},"streams":[{"codec_type":"video"}]}',
|
||||
stderr_text="",
|
||||
elapsed_ms=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tgvideo_check.cli.run_ffprobe", fake_ffprobe)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"scan",
|
||||
str(parent),
|
||||
"--db",
|
||||
str(db_path),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
run_count = connection.execute("SELECT COUNT(*) FROM scan_runs").fetchone()[0]
|
||||
file_count = connection.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
||||
stage1_count = connection.execute("SELECT COUNT(*) FROM stage1_results").fetchone()[0]
|
||||
remaining_root_paths_json = connection.execute(
|
||||
"SELECT root_paths_json FROM scan_runs"
|
||||
).fetchone()[0]
|
||||
legacy_row = connection.execute(
|
||||
"""
|
||||
SELECT stage1_status, final_status
|
||||
FROM files
|
||||
WHERE path = ?
|
||||
""",
|
||||
(str(legacy_video),),
|
||||
).fetchone()
|
||||
|
||||
assert run_count == 1
|
||||
assert file_count == 2
|
||||
assert stage1_count == 2
|
||||
assert orjson.loads(remaining_root_paths_json) == [str(parent)]
|
||||
assert legacy_row == ("passed_stage1", "passed_stage1")
|
||||
assert probe_calls == [fresh_video]
|
||||
|
||||
|
||||
def test_scan_does_not_compact_historical_run_when_nested_file_changed(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
parent = tmp_path / "a"
|
||||
nested = parent / "b"
|
||||
nested.mkdir(parents=True)
|
||||
legacy_video = nested / "legacy.mp4"
|
||||
legacy_video.write_bytes(b"legacy")
|
||||
db_path = tmp_path / "scan_state.db"
|
||||
|
||||
store = ScanStore(db_path)
|
||||
store.initialize()
|
||||
legacy_run = store.create_run(profile="tg-hdd", root_paths=(nested,))
|
||||
legacy_candidate = FileCandidate(
|
||||
path=legacy_video,
|
||||
size=legacy_video.stat().st_size,
|
||||
mtime_ns=legacy_video.stat().st_mtime_ns,
|
||||
extension="mp4",
|
||||
discovery_order=0,
|
||||
)
|
||||
[legacy_stored] = store.upsert_files(legacy_run, [legacy_candidate])
|
||||
store.record_stage1_result(
|
||||
legacy_stored.file_id,
|
||||
exit_code=0,
|
||||
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"},"streams":[{"codec_type":"video"}]}',
|
||||
stderr_text="",
|
||||
elapsed_ms=1,
|
||||
)
|
||||
store.record_stage1_status(
|
||||
legacy_stored.file_id,
|
||||
stage1_status="passed_stage1",
|
||||
final_status="passed_stage1",
|
||||
reason_code="passed_stage1",
|
||||
)
|
||||
store.update_run_status(legacy_run, status="completed")
|
||||
|
||||
time.sleep(0.01)
|
||||
legacy_video.write_bytes(b"legacy-changed")
|
||||
|
||||
def fake_ffprobe(path: Path) -> ProbeExecutionResult:
|
||||
return ProbeExecutionResult(
|
||||
status="completed",
|
||||
command=("ffprobe", str(path)),
|
||||
exit_code=0,
|
||||
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"},"streams":[{"codec_type":"video"}]}',
|
||||
stderr_text="",
|
||||
elapsed_ms=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("tgvideo_check.cli.run_ffprobe", fake_ffprobe)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"scan",
|
||||
str(parent),
|
||||
"--db",
|
||||
str(db_path),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
run_count = connection.execute("SELECT COUNT(*) FROM scan_runs").fetchone()[0]
|
||||
stage1_count = connection.execute("SELECT COUNT(*) FROM stage1_results").fetchone()[0]
|
||||
|
||||
assert run_count == 2
|
||||
assert stage1_count == 2
|
||||
|
||||
|
||||
def test_compact_db_command_keeps_selected_run_and_removes_duplicate_runs(tmp_path: Path) -> None:
|
||||
root = tmp_path / "videos"
|
||||
root.mkdir()
|
||||
|
||||
+260
-10
@@ -16,6 +16,55 @@ def make_candidate(path: Path, *, order: int, size: int = 10, mtime_ns: int = 10
|
||||
)
|
||||
|
||||
|
||||
def create_legacy_schema(db_path: Path) -> None:
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE scan_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
profile TEXT NOT NULL,
|
||||
root_paths_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
);
|
||||
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mtime_ns INTEGER NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
discovery_order INTEGER NOT NULL,
|
||||
stage1_status TEXT NOT NULL,
|
||||
stage2_status TEXT,
|
||||
final_status TEXT,
|
||||
resumed_from_run_id INTEGER,
|
||||
reason_code TEXT,
|
||||
UNIQUE (run_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE stage1_results (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
exit_code INTEGER,
|
||||
stdout_text TEXT,
|
||||
stderr_text TEXT,
|
||||
elapsed_ms INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE stage2_results (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
mode TEXT,
|
||||
sample_points_json TEXT,
|
||||
exit_code INTEGER,
|
||||
stderr_text TEXT,
|
||||
reason_code TEXT,
|
||||
elapsed_ms INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_initialize_creates_expected_tables(tmp_path: Path) -> None:
|
||||
store = ScanStore(tmp_path / "scan_state.db")
|
||||
|
||||
@@ -29,7 +78,7 @@ def test_initialize_creates_expected_tables(tmp_path: Path) -> None:
|
||||
)
|
||||
}
|
||||
|
||||
assert {"scan_runs", "files", "stage1_results", "stage2_results"} <= table_names
|
||||
assert {"scan_runs", "files", "file_entities", "stage1_results", "stage2_results"} <= table_names
|
||||
|
||||
|
||||
def test_upsert_files_persists_candidates_in_discovery_order(tmp_path: Path) -> None:
|
||||
@@ -69,6 +118,13 @@ def test_upsert_files_reuses_previous_status_when_identity_matches(tmp_path: Pat
|
||||
assert resumed.final_status == "passed_stage1"
|
||||
assert resumed.resumed_from_run_id == first_run
|
||||
|
||||
with sqlite3.connect(store.db_path) as connection:
|
||||
stage1_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results"
|
||||
).fetchone()[0]
|
||||
|
||||
assert stage1_count == 0
|
||||
|
||||
|
||||
def test_upsert_files_can_skip_reusing_previous_stage2_results(tmp_path: Path) -> None:
|
||||
store = ScanStore(tmp_path / "scan_state.db")
|
||||
@@ -170,12 +226,16 @@ def test_upsert_files_same_run_preserves_existing_status_without_duplication(
|
||||
"SELECT COUNT(*) FROM files WHERE run_id = ?",
|
||||
(run_id,),
|
||||
).fetchone()[0]
|
||||
entity_count = connection.execute(
|
||||
"SELECT COUNT(DISTINCT entity_id) FROM files WHERE run_id = ?",
|
||||
(run_id,),
|
||||
).fetchone()[0]
|
||||
stage1_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results WHERE file_id = ?",
|
||||
(stored.file_id,),
|
||||
"SELECT COUNT(*) FROM stage1_results",
|
||||
).fetchone()[0]
|
||||
|
||||
assert file_count == 1
|
||||
assert entity_count == 1
|
||||
assert stage1_count == 1
|
||||
|
||||
|
||||
@@ -202,6 +262,11 @@ def test_upsert_files_same_run_changed_file_clears_stale_stage_results(
|
||||
final_status="passed_stage1",
|
||||
reason_code="passed_stage1",
|
||||
)
|
||||
with sqlite3.connect(store.db_path) as connection:
|
||||
original_entity_id = connection.execute(
|
||||
"SELECT entity_id FROM files WHERE id = ?",
|
||||
(stored.file_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
changed = FileCandidate(
|
||||
path=file_path,
|
||||
@@ -218,14 +283,199 @@ def test_upsert_files_same_run_changed_file_clears_stale_stage_results(
|
||||
assert refreshed.reason_code is None
|
||||
|
||||
with sqlite3.connect(store.db_path) as connection:
|
||||
stage1_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results WHERE file_id = ?",
|
||||
current_entity_id = connection.execute(
|
||||
"SELECT entity_id FROM files WHERE id = ?",
|
||||
(stored.file_id,),
|
||||
).fetchone()[0]
|
||||
stage2_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage2_results WHERE file_id = ?",
|
||||
(stored.file_id,),
|
||||
new_entity_stage1_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results WHERE entity_id = ?",
|
||||
(current_entity_id,),
|
||||
).fetchone()[0]
|
||||
old_entity_stage1_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results WHERE entity_id = ?",
|
||||
(original_entity_id,),
|
||||
).fetchone()[0]
|
||||
new_entity_stage2_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage2_results WHERE entity_id = ?",
|
||||
(current_entity_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
assert stage1_count == 0
|
||||
assert stage2_count == 0
|
||||
assert current_entity_id != original_entity_id
|
||||
assert new_entity_stage1_count == 0
|
||||
assert old_entity_stage1_count == 1
|
||||
assert new_entity_stage2_count == 0
|
||||
|
||||
|
||||
def test_initialize_migrates_legacy_schema_to_entity_storage_without_duplicate_stage_results(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db_path = tmp_path / "scan_state.db"
|
||||
create_legacy_schema(db_path)
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO scan_runs(id, profile, root_paths_json, status)
|
||||
VALUES (1, 'tg-hdd', '["root"]', 'failed')
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO scan_runs(id, profile, root_paths_json, status)
|
||||
VALUES (2, 'tg-hdd', '["root"]', 'running')
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO files(
|
||||
id, run_id, path, size, mtime_ns, extension, discovery_order,
|
||||
stage1_status, stage2_status, final_status, resumed_from_run_id, reason_code
|
||||
)
|
||||
VALUES
|
||||
(11, 1, ?, 12, 999, 'mp4', 0, 'passed_stage1', NULL, 'passed_stage1', NULL, 'passed_stage1'),
|
||||
(12, 2, ?, 12, 999, 'mp4', 0, 'passed_stage1', NULL, 'passed_stage1', 1, 'passed_stage1')
|
||||
""",
|
||||
(str(tmp_path / "clip.mp4"), str(tmp_path / "clip.mp4")),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO stage1_results(file_id, exit_code, stdout_text, stderr_text, elapsed_ms)
|
||||
VALUES
|
||||
(11, 0, '{"legacy":"first"}', '', 10),
|
||||
(12, 0, '{"legacy":"latest"}', '', 12)
|
||||
"""
|
||||
)
|
||||
|
||||
store = ScanStore(db_path)
|
||||
store.initialize()
|
||||
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
table_names = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
entity_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM file_entities"
|
||||
).fetchone()[0]
|
||||
file_rows = connection.execute(
|
||||
"SELECT COUNT(*) FROM files"
|
||||
).fetchone()[0]
|
||||
stage1_rows = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results"
|
||||
).fetchone()[0]
|
||||
migrated_stdout = connection.execute(
|
||||
"SELECT stdout_text FROM stage1_results"
|
||||
).fetchone()[0]
|
||||
entity_links = connection.execute(
|
||||
"SELECT COUNT(DISTINCT entity_id) FROM files"
|
||||
).fetchone()[0]
|
||||
|
||||
assert "file_entities" in table_names
|
||||
assert entity_count == 1
|
||||
assert file_rows == 2
|
||||
assert stage1_rows == 1
|
||||
assert migrated_stdout == '{"legacy":"latest"}'
|
||||
assert entity_links == 1
|
||||
|
||||
|
||||
def test_stage1_results_are_shared_across_runs_by_entity(tmp_path: Path) -> None:
|
||||
store = ScanStore(tmp_path / "scan_state.db")
|
||||
store.initialize()
|
||||
|
||||
first_run = store.create_run(profile="tg-hdd", root_paths=(tmp_path,))
|
||||
candidate = make_candidate(tmp_path / "clip.mp4", order=0, size=12, mtime_ns=999)
|
||||
[first] = store.upsert_files(first_run, [candidate])
|
||||
store.record_stage1_result(
|
||||
first.file_id,
|
||||
exit_code=0,
|
||||
stdout_text='{"shared":"yes"}',
|
||||
stderr_text="",
|
||||
elapsed_ms=10,
|
||||
)
|
||||
store.record_stage1_status(
|
||||
first.file_id,
|
||||
stage1_status="passed_stage1",
|
||||
final_status="passed_stage1",
|
||||
reason_code="passed_stage1",
|
||||
)
|
||||
|
||||
second_run = store.create_run(profile="tg-hdd", root_paths=(tmp_path,))
|
||||
[second] = store.upsert_files(second_run, [candidate])
|
||||
|
||||
assert second.stage1_status == "passed_stage1"
|
||||
assert second.final_status == "passed_stage1"
|
||||
|
||||
with sqlite3.connect(store.db_path) as connection:
|
||||
stage1_rows = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results"
|
||||
).fetchone()[0]
|
||||
distinct_entities = connection.execute(
|
||||
"SELECT COUNT(DISTINCT entity_id) FROM files"
|
||||
).fetchone()[0]
|
||||
|
||||
assert stage1_rows == 1
|
||||
assert distinct_entities == 1
|
||||
|
||||
|
||||
def test_compact_duplicate_runs_removes_orphan_entities_and_stage_results(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = ScanStore(tmp_path / "scan_state.db")
|
||||
store.initialize()
|
||||
|
||||
first_run = store.create_run(profile="tg-hdd", root_paths=(tmp_path,))
|
||||
common = make_candidate(tmp_path / "common.mp4", order=0, size=12, mtime_ns=999)
|
||||
stale_only = make_candidate(tmp_path / "stale.mp4", order=1, size=13, mtime_ns=1000)
|
||||
first_rows = store.upsert_files(first_run, [common, stale_only])
|
||||
for item, stdout_text in zip(first_rows, ('{"file":"common"}', '{"file":"stale"}'), strict=True):
|
||||
store.record_stage1_result(
|
||||
item.file_id,
|
||||
exit_code=0,
|
||||
stdout_text=stdout_text,
|
||||
stderr_text="",
|
||||
elapsed_ms=10,
|
||||
)
|
||||
store.record_stage1_status(
|
||||
item.file_id,
|
||||
stage1_status="passed_stage1",
|
||||
final_status="passed_stage1",
|
||||
reason_code="passed_stage1",
|
||||
)
|
||||
|
||||
second_run = store.create_run(profile="tg-hdd", root_paths=(tmp_path,))
|
||||
[second_common] = store.upsert_files(second_run, [common])
|
||||
store.record_stage1_status(
|
||||
second_common.file_id,
|
||||
stage1_status="passed_stage1",
|
||||
final_status="passed_stage1",
|
||||
reason_code="passed_stage1",
|
||||
)
|
||||
|
||||
summary = store.compact_duplicate_runs(second_run, dry_run=False, vacuum=False)
|
||||
|
||||
with sqlite3.connect(store.db_path) as connection:
|
||||
remaining_runs = connection.execute(
|
||||
"SELECT COUNT(*) FROM scan_runs"
|
||||
).fetchone()[0]
|
||||
remaining_files = connection.execute(
|
||||
"SELECT COUNT(*) FROM files"
|
||||
).fetchone()[0]
|
||||
remaining_entities = connection.execute(
|
||||
"SELECT COUNT(*) FROM file_entities"
|
||||
).fetchone()[0]
|
||||
remaining_stage1 = connection.execute(
|
||||
"SELECT COUNT(*) FROM stage1_results"
|
||||
).fetchone()[0]
|
||||
remaining_stdout = {
|
||||
row[0]
|
||||
for row in connection.execute("SELECT stdout_text FROM stage1_results")
|
||||
}
|
||||
|
||||
assert summary.deleted_run_count == 1
|
||||
assert remaining_runs == 1
|
||||
assert remaining_files == 1
|
||||
assert remaining_entities == 1
|
||||
assert remaining_stage1 == 1
|
||||
assert remaining_stdout == {'{"file":"common"}'}
|
||||
|
||||
Reference in New Issue
Block a user