tests/test_processing_control.py
Rama master · réplica de lectura del repositorio original.
"""馆藏处理控制与启动粗估的隔离行为回归。"""
import json
import tempfile
import threading
import time
import unittest
from contextlib import closing
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from peach import job_recovery
from peach.processing_control import (CURRENT, ProcessingControl, ProcessingEstimate,
checkpoint, control_for, initial_estimate, user_paused, deadline_after)
from peach.settings_file import PeachConfig
from peach.web_library_processing import w_library_processing
from support.ledger import fresh_ledger
class ProcessingControlTests(unittest.TestCase):
def assert_processing_status(self, contract, expected):
from peach.web_library_processing import q_library_processing
deadline = time.monotonic() + 5
while True:
state = q_library_processing(contract, {})
if state['status'] == expected or time.monotonic() >= deadline:
self.assertEqual(state['status'], expected)
return state
threading.Event().wait(.01)
def test_failed_worker_keeps_retries_out_until_its_real_lock_is_released(self):
from filelock import FileLock, Timeout
from peach.jobs import BackgroundJob
from peach.library_processing import _save, state_path
from peach.web_library_processing import _current
job = BackgroundJob('library-processing')
contract = SimpleNamespace(db_path=self.db, library_processing_job=job)
path = state_path(self.config)
path.parent.mkdir(parents=True, exist_ok=True)
_save(path, {'job_id': 'old', 'status': 'failed', 'started_at': 1, 'error': '旧错误'})
failed, release = threading.Event(), threading.Event()
def work(job_id):
with FileLock(str(path) + '.lock', timeout=0):
job.update(job_id, status='failed', error='当前处理失败')
failed.set()
release.wait(10)
job.start(work)
self.addCleanup(release.set)
self.assertTrue(failed.wait(5))
identity = job.snapshot()['job_id']
try:
with patch('peach.web_library_processing.settings_file.active', return_value=self.config):
with self.assertRaises(Timeout), FileLock(str(path) + '.lock', timeout=0):
pass
state = _current(contract)
self.assertEqual((state['job_id'], state['status']), (identity, 'stopping'))
retried = w_library_processing(contract, {'retry': True})
self.assertEqual((retried['job_id'], retried['status']), (identity, 'stopping'))
release.set()
job.thread.join(5)
self.assertFalse(job.thread.is_alive())
with FileLock(str(path) + '.lock', timeout=0):
pass
current = _current(contract)
self.assertEqual((current['job_id'], current['error']), (identity, '当前处理失败'))
_save(path, {'job_id': 'external', 'status': 'complete',
'started_at': job.snapshot()['started_at'] + 1})
self.assertEqual(_current(contract)['job_id'], 'external')
finally:
release.set()
job.thread.join(5)
def test_skip_intent_is_shared_scoped_and_cleared_before_next_asset(self):
from peach.processing_control import ItemSkipped, processing_item
control = ProcessingControl(self.root / 'skip-control.json')
with control.bind('job', publish=lambda **values: None, active=lambda: True, db_path=self.db):
with self.assertRaises(ItemSkipped), processing_item(1):
other = ProcessingControl(control.path)
other.skip_current('job', 1)
control.refresh(force=True)
checkpoint('network')
with processing_item(2):
checkpoint('network')
self.assertIsNone(ProcessingControl(control.path).skip_asset_id)
with self.assertRaises(ValueError):
control.skip_current('stale', 2)
def test_skip_action_rejects_stale_asset_and_local_work(self):
contract = SimpleNamespace(library_processing_job=Mock())
state = {'job_id': 'current', 'status': 'running', 'current_asset_id': 1, 'current_action': 'querying_metadata'}
contract.library_processing_job.snapshot.return_value = state
with patch('peach.web_library_processing.settings_file.active', return_value=self.config):
for body in ({'asset_id': 2}, {'asset_id': True}, {'asset_id': '1'}):
with self.assertRaises(ValueError):
w_library_processing(contract, {'action': 'skip_current', 'job_id': 'current', **body})
state['current_action'] = 'generating_preview'
with self.assertRaises(ValueError):
w_library_processing(contract, {'action': 'skip_current', 'job_id': 'current', 'asset_id': 1})
def test_skip_preserves_published_results_and_records_retryable_item(self):
from peach.processing_control import processing_item
from peach.library_processing import _collect_allowed
control = ProcessingControl(self.root / 'partial-skip.json')
remote, traffic = Mock(), Mock()
published, issues = [], []
def collect(*args, **options):
options['received']([('javdb', {'title': '已取得的资料'}, self.root / 'evidence.json')])
control.skip_current('job', 1)
checkpoint('network')
remote.collect.side_effect = collect
with control.bind('job', publish=lambda **values: None, active=lambda: True, db_path=self.db):
result = _collect_allowed(remote, traffic, {'id': 1}, 'ABC-123', ['title'], {'metadata'},
self.root, [], update=lambda **values: None,
issue=lambda row, message, **values: issues.append(values), answered=(),
received=published.extend, succeeded=lambda: not issues)
self.assertEqual(result, ([], 0))
self.assertEqual(published[0][1]['title'], '已取得的资料')
self.assertEqual(issues[0], {'action': 'skipped', 'retryable': True})
remote.reset.assert_called_once()
traffic.finish_work.assert_called_with('metadata', 'ABC-123', successful=False)
with processing_item(2):
checkpoint('network')
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
self.config = PeachConfig(self.root, self.root / 'config.toml', present=True,
locations={'local': ('R:\\Media',)})
self.db = fresh_ledger(self.root)
def seed(self, count=10):
import sqlite3
with closing(sqlite3.connect(self.db)) as connection, connection:
for index in range(count):
connection.execute('INSERT INTO asset(location,path,name,medium,size) VALUES(?,?,?,?,?)',
('local', f'R:\\Media\\{index}.mp4', f'{index}.mp4', 'video', 1024**3))
def test_start_estimate_uses_known_count_size_and_scoped_selection(self):
self.seed()
estimate = initial_estimate(self.config, self.db, 'all')
chosen = initial_estimate(self.config, self.db, 'all', [1, 2])
low = initial_estimate(self.config, self.db, 'all', mode='low')
high = initial_estimate(self.config, self.db, 'all', mode='high')
self.assertEqual(estimate['estimate_file_count'], 10)
self.assertEqual(estimate['estimate_bytes'], 10 * 1024**3)
self.assertGreater(estimate['eta_seconds_max'], estimate['eta_seconds_min'])
self.assertLess(chosen['eta_seconds_max'], estimate['eta_seconds_max'])
self.assertGreater(low['eta_seconds_max'], estimate['eta_seconds_max'])
self.assertLess(high['eta_seconds_max'], estimate['eta_seconds_max'])
self.assertTrue(estimate['eta_initial'])
self.assertIn('未含未知新增', estimate['eta_scope'])
def test_new_directory_reports_counting_without_invented_time(self):
estimate = initial_estimate(self.config, self.db, 'scan')
self.assertIsNone(estimate['eta_seconds_min'])
self.assertIsNone(estimate['eta_seconds_max'])
self.assertIn('统计', estimate['eta_basis'])
def test_progress_calibrates_range_and_paused_time_does_not_count(self):
self.seed()
state = dict(initial_estimate(self.config, self.db, 'metadata'),
requested_stage='metadata', status='running', stage='读取本地资料', checked=0, total=10)
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, None)
estimate.update(state)
state['checked'] = 2
with patch('peach.processing_control.time.monotonic', return_value=110):
estimate.update(state)
self.assertEqual(state['eta_kind'], 'measured')
self.assertFalse(state['eta_initial'])
self.assertLessEqual(state['eta_seconds_min'], 40)
self.assertGreaterEqual(state['eta_seconds_max'], 40)
frozen = (state['eta_seconds_min'], state['eta_seconds_max'])
state['status'] = 'paused'
with patch('peach.processing_control.time.monotonic', return_value=9999):
estimate.update(state)
self.assertEqual((state['eta_seconds_min'], state['eta_seconds_max']), frozen)
def test_metadata_passes_use_independent_measured_samples(self):
self.seed()
state = dict(initial_estimate(self.config, self.db, 'metadata'),
requested_stage='metadata', status='running', stage='读取本地资料', checked=0, total=10)
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, None)
estimate.update(state)
state['checked'] = 3
with patch('peach.processing_control.time.monotonic', return_value=103):
estimate.update(state)
self.assertEqual(state['eta_kind'], 'measured')
state.update(stage='采集缺失资料', checked=0)
with patch('peach.processing_control.time.monotonic', return_value=200):
estimate.update(state)
self.assertTrue(state['eta_initial'])
self.assertEqual(state['eta_kind'], 'estimating')
self.assertEqual(state['eta_scope'], '整趟任务')
state['checked'] = 2
with patch('peach.processing_control.time.monotonic', return_value=204):
estimate.update(state)
self.assertLessEqual(state['eta_seconds_min'], 16)
self.assertGreaterEqual(state['eta_seconds_max'], 16)
self.assertLess(state['eta_seconds_max'], 40)
def test_total_estimate_includes_pending_media_and_collection(self):
state = dict(speed_mode='standard', status='running', requested_stage='all',
stage='读取本地资料', checked=0, total=10,
estimate_pending={'local': 10, 'probe': 2, 'previews': 3, 'metadata': 10})
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, None)
estimate.update(state)
state['checked'] = 8
with patch('peach.processing_control.time.monotonic', return_value=108):
estimate.update(state)
self.assertEqual(state['eta_scope'], '整趟任务')
self.assertTrue(state['eta_initial'])
self.assertGreater(state['eta_seconds_min'], 40)
state.update(checked=0, total=2, stage='采集缺失资料', estimate_pending={'metadata': 2})
with patch('peach.processing_control.time.monotonic', return_value=120):
estimate.update(state)
self.assertLessEqual(state['eta_seconds_max'], 40)
def test_unmeasured_finalization_does_not_keep_a_completed_phase_estimate(self):
state = dict(speed_mode='standard', status='running', requested_stage='metadata',
stage='保存待确认资料', checked=10, total=10, estimate_pending={},
eta_seconds_min=1, eta_seconds_max=2)
estimate = ProcessingEstimate(self.config, state, None)
estimate.update(state)
self.assertIsNone(state['eta_seconds_max'])
def test_slow_tail_uses_recent_completions_instead_of_the_fast_prefix(self):
state = dict(speed_mode='standard', status='running', requested_stage='metadata',
stage='读取本地资料', checked=0, total=120, estimate_pending={'local': 120})
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, None)
estimate.update(state)
for done in range(1, 101):
state['checked'] = done
elapsed = done if done <= 80 else 80 + (done - 80) * 10
with patch('peach.processing_control.time.monotonic', return_value=100 + elapsed):
estimate.update(state)
self.assertGreaterEqual(state['eta_seconds_min'], 150)
self.assertGreaterEqual(state['eta_seconds_max'], 240)
def test_progress_count_rewind_starts_a_new_sample_for_same_stage(self):
self.seed()
state = dict(initial_estimate(self.config, self.db, 'metadata'),
requested_stage='metadata', status='running', stage='读取时长与分辨率', checked=0, total=10)
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, None)
estimate.update(state)
state['checked'] = 5
with patch('peach.processing_control.time.monotonic', return_value=150):
estimate.update(state)
state['checked'] = 0
with patch('peach.processing_control.time.monotonic', return_value=200):
estimate.update(state)
self.assertEqual(state['eta_kind'], 'estimating')
state['checked'] = 2
with patch('peach.processing_control.time.monotonic', return_value=202):
estimate.update(state)
self.assertLessEqual(state['eta_seconds_min'], 8)
self.assertGreaterEqual(state['eta_seconds_max'], 8)
self.assertLess(state['eta_seconds_max'], 20)
def test_pause_waits_at_checkpoint_and_resume_releases_same_job(self):
control = control_for(self.config)
entered, paused, finished = threading.Event(), threading.Event(), threading.Event()
errors = []
def publish(**values):
if values['status'] == 'paused':
paused.set()
def worker():
try:
with control.bind('one', publish=publish, active=lambda: True, db_path=self.db, mode='high'):
entered.set()
control.request('pause', 'one')
checkpoint('probe')
finished.set()
except BaseException as exc:
errors.append(exc)
thread = threading.Thread(target=worker)
thread.start()
self.addCleanup(lambda: control.request('resume', 'one'))
self.addCleanup(lambda: thread.join(2))
self.assertTrue(entered.wait(2))
self.assertTrue(paused.wait(2))
self.assertFalse(finished.is_set())
self.assertTrue(user_paused(self.db, 'one'))
control.request('resume', 'one')
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertTrue(finished.is_set())
self.assertEqual(errors, [])
self.assertFalse(user_paused(self.db, 'one'))
def test_saved_pause_intent_prevents_automatic_restart(self):
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='high'):
control.request('pause', 'one')
restored = ProcessingControl(control.path)
self.assertTrue(restored.paused)
self.assertEqual(restored.mode, 'high')
job_recovery.save(self.db, 'library-processing', 'one', {'stage': 'scan'}, waiting=True)
handler = Mock()
self.assertEqual(job_recovery.resume(self.db, {'library-processing': handler}), [])
handler.assert_not_called()
def test_low_mode_leaves_io_gaps_and_preserves_unrelated_context(self):
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='low'), \
patch('peach.processing_control.time.monotonic', return_value=10), \
patch.object(control.event, 'wait', side_effect=lambda _: control.request('set_speed', mode='standard')) as wait:
checkpoint('network')
checkpoint('network')
wait.assert_called_once()
self.assertIsNone(CURRENT.get())
checkpoint('network')
restored = ProcessingControl(control.path)
self.assertEqual(restored.mode, 'standard')
def test_stale_job_control_is_rejected(self):
contract = SimpleNamespace(library_processing_job=Mock())
contract.library_processing_job.snapshot.return_value = {'job_id': 'current', 'status': 'running'}
with patch('peach.web_library_processing.settings_file.active', return_value=self.config):
with self.assertRaisesRegex(ValueError, '任务已变化'):
w_library_processing(contract, {'action': 'pause', 'job_id': 'stale'})
with self.assertRaisesRegex(ValueError, '任务已变化'):
w_library_processing(contract, {'action': 'set_speed', 'job_id': 'stale', 'speed_mode': 'low'})
def test_completed_timing_is_available_for_next_initial_estimate(self):
self.seed()
state = dict(initial_estimate(self.config, self.db, 'metadata'),
requested_stage='metadata', status='running')
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, None)
state['status'] = 'complete'
with patch('peach.processing_control.time.monotonic', return_value=200):
estimate.update(state)
next_run = initial_estimate(self.config, self.db, 'metadata')
self.assertEqual(next_run['eta_kind'], 'historical')
self.assertTrue(next_run['eta_initial'])
self.assertEqual(json.loads((self.config.directory('state') / 'library-processing-timing.json').read_text()),
{'metadata': 10.0})
def test_action_deadline_keeps_the_same_budget_after_user_pause(self):
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db), \
patch('peach.processing_control.time.monotonic', return_value=100):
deadline = deadline_after(90)
self.assertEqual(deadline - 110, 80)
control.paused_seconds += 3600
self.assertEqual(deadline - 3710, 80)
self.assertEqual((deadline + 5) - 3710, 85)
self.assertTrue(3710 < deadline)
self.assertTrue(3800 > deadline)
def test_cancel_crosses_process_control_and_releases_a_paused_checkpoint(self):
from peach.jobs import JobCancelled
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db):
control.request('pause', 'one')
external = ProcessingControl(control.path)
external.request('cancel', 'one')
control.refresh(force=True)
with self.assertRaises(JobCancelled):
checkpoint()
self.assertFalse(control.paused)
control.select_job('two')
self.assertFalse(control.cancelled)
def test_new_asset_ranges_keep_later_arrivals_out_of_an_existing_request(self):
from peach.library_new_assets import pending, remember
from peach.web_library_processing import _processing_request
from peach.repository import LedgerDatabase
root=self.config.directory('database')
root.mkdir(parents=True)
db=fresh_ledger(root)
database=LedgerDatabase(db)
with database.write_transaction() as connection:
for key in (1,2,3):
connection.execute("INSERT INTO asset(id,location,path,name,medium) VALUES(?,'local',?,?,'video')",
(key,f'R:\\Media\\{key}.mp4',f'{key}.mp4'))
contract=SimpleNamespace(db_path=db,database=database)
remember(db,[1,2])
_,ids,stage=_processing_request(contract,self.config,{}, {'stage':'collect','scope':'new'})
self.assertEqual(ids,[1,2])
remember(db,[3])
self.assertEqual(_processing_request(contract,self.config,{}, {'stage':stage,'asset_ids':ids})[1],[1,2])
remember(db,completed=ids)
self.assertEqual(pending(db),[3])
with self.assertRaises(ValueError):
_processing_request(contract,self.config,{}, {'scope':'new','retry':[]})
remember(db,completed=[3])
with self.assertRaisesRegex(ValueError,'暂无'):
_processing_request(contract,self.config,{}, {'scope':'new'})
def test_new_asset_range_imports_a_legacy_scan_checkpoint(self):
import json
from peach.library_new_assets import pending
from peach.update_tasks import state_for
self.seed(3)
state=state_for(self.db).parent/'library-processing.json'
state.parent.mkdir(parents=True,exist_ok=True)
state.write_text(json.dumps({'status':'complete','requested_stage':'scan',
'scan_checkpoints':{'root':{'result':{'new_ids':[1,2,3]}}},
'completed_metadata':['1:R:\\Media\\0.mp4']}),encoding='utf-8')
self.assertEqual(pending(self.db),[2,3])
def test_new_asset_range_is_read_under_the_writer_lock(self):
"""`remember` 持锁原子替换文件;Windows 替换瞬间读会撞上共享冲突,所以读也要等同一把锁。"""
from filelock import FileLock
from peach.library_new_assets import pending
from peach.update_tasks import state_for
self.seed(3)
path=state_for(self.db).parent/'library-new-assets.json'
path.parent.mkdir(parents=True,exist_ok=True)
outcome={}
def read():
try:
outcome['ids']=pending(self.db)
except Exception as error:
outcome['error']=error
with FileLock(str(path)+'.lock'):
path.write_text('[1, 2',encoding='utf-8')
reader=threading.Thread(target=read)
reader.start()
reader.join(0.5)
self.assertTrue(reader.is_alive(),f'写入方持锁期间读到了半份文件:{outcome}')
path.write_text('[1, 2]',encoding='utf-8')
reader.join(5)
self.assertEqual(outcome,{'ids':[1,2]})
def test_new_asset_range_is_empty_before_any_state_exists(self):
from peach.library_new_assets import pending
self.seed(1)
self.assertEqual(pending(self.db),[])
def test_archiving_keeps_full_legacy_issues_and_export_rejects_changes(self):
import json
from peach.library_processing import state_path,_save
from peach.web_library_processing import q_library_processing_issues
from peach.repository import LedgerDatabase
root=self.config.directory('database')
root.mkdir(parents=True)
db=fresh_ledger(root)
job=Mock()
job.snapshot.return_value=None
contract=SimpleNamespace(db_path=db,database=LedgerDatabase(db),library_processing_job=job)
state={'job_id':'legacy','status':'failed','issue_count':250,
'issues':[{'asset_id':None,'message':f'问题 {index}'} for index in range(250)]}
_save(state_path(self.config),state)
with patch('peach.web_library_processing.settings_file.active',return_value=self.config):
archived=w_library_processing(contract,{'action':'archive','job_id':'legacy'})
self.assertTrue(archived['archived'])
self.assertEqual(json.loads(state_path(self.config).read_text(encoding='utf-8'))['issues'],state['issues'])
first=q_library_processing_issues(contract,{'job_id':'legacy','limit':200})
last=q_library_processing_issues(contract,{'job_id':'legacy','limit':200,'offset':200,'revision':first['revision']})
self.assertEqual(first['rows']+last['rows'],state['issues'])
restored=w_library_processing(contract,{'action':'unarchive','job_id':'legacy'})
self.assertFalse(restored['archived'],'撤销归档后提示回来')
self.assertEqual(json.loads(state_path(self.config).read_text(encoding='utf-8'))['issues'],state['issues'])
state['issues'].append({'asset_id':None,'message':'恢复后新增的问题'})
_save(state_path(self.config),state)
with self.assertRaisesRegex(ValueError,'变化'):
q_library_processing_issues(contract,{'job_id':'legacy','offset':200,'revision':first['revision']})
def test_retry_set_removes_retired_assets_without_testing_mount_availability(self):
from peach.repository import LedgerDatabase
from peach.web_library_processing import _remaining_retry_ids
self.seed(3)
database=LedgerDatabase(self.db)
with database.write_transaction() as connection:
connection.execute("UPDATE asset SET disposal='vanished' WHERE id=2")
connection.execute('DELETE FROM asset WHERE id=3')
contract=SimpleNamespace(database=database)
self.assertEqual(_remaining_retry_ids(contract,{'retryable_asset_ids':[1,2,3]}),[1])
def test_cancelled_recovery_is_not_replayed_after_the_writer_exits(self):
from peach.task_runs import TaskRunStore
store = TaskRunStore(self.db)
run = store.start('library-processing', trigger='manual')
job_recovery.save(self.db, 'library-processing', 'one', {'stage':'metadata'},
run_id=run.id, waiting=True)
control = control_for(self.config)
control.select_job('one')
control.request('cancel', 'one')
handler = Mock()
self.assertEqual(job_recovery.resume(self.db, {'library-processing':handler}), [])
handler.assert_not_called()
self.assertEqual(store.get(run.id).status, 'cancelled')
self.assertEqual(job_recovery.read(self.db, 'library-processing')['status'], 'cancelled')
def test_cancel_waits_for_inflight_work_and_retains_results(self):
from peach.jobs import BackgroundJob
from peach.library_processing import _save, state_path
from peach.task_runs import TaskRunStore
from peach.web_library_processing import q_library_processing
root = self.config.directory('database')
root.mkdir(parents=True)
db = fresh_ledger(root)
job = BackgroundJob('library-processing', runs=TaskRunStore(db))
self.addCleanup(job.stop, timeout=None)
contract = SimpleNamespace(db_path=db, library_processing_job=job, database=job.runs.database,
task_runs=job.runs,
candidate_root=self.root/'candidates', cover_root=self.root/'covers', cache_bust=lambda:None)
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
def process(*_args, **kwargs):
identity = kwargs['job_id']
with control_for(self.config).bind(identity, publish=lambda **v:kwargs['report'](v),
active=lambda:True, db_path=db):
_save(state_path(self.config), {'job_id':identity,'status':'running','candidates':3})
kwargs['report']({'candidates':3})
entered.set()
release.wait(5)
checkpoint()
self.fail('取消后不能领取下一项')
with patch('peach.web_library_processing.settings_file.active',return_value=self.config), patch(
'peach.web_library_processing.process_library',side_effect=process):
initial=w_library_processing(contract,{'stage':'metadata'})
self.assertTrue(entered.wait(2))
try:
state=w_library_processing(contract,{'action':'cancel','job_id':initial['job_id']})
self.assertEqual(state['status'],'stopping')
self.assertEqual(job.runs.get(initial['run_id']).status,'running')
self.assertEqual(w_library_processing(contract,{'stage':'collect'})['job_id'],initial['job_id'])
finally:
release.set()
job.thread.join(5)
self.assertFalse(job.thread.is_alive())
self.assertEqual(job.snapshot()['status'],'cancelled')
self.assertEqual(job.snapshot()['candidates'],3)
self.assertEqual(job.runs.get(initial['run_id']).status,'cancelled')
self.assertEqual(q_library_processing(contract,{})['status'],'cancelled')
self.assertEqual(job_recovery.resume(db,{'library-processing':Mock()}),[])
def test_status_names_how_a_command_line_run_was_started(self):
from peach.task_runs import TaskRunStore
from peach.web_library_processing import q_library_processing
root = self.config.directory('database')
root.mkdir(parents=True)
db = fresh_ledger(root)
runs = TaskRunStore(db)
# 命令行那一轮在另一个进程里写状态;这里用一份进行中的快照代替,状态里没有 `run_id`。
job = Mock()
job.snapshot.return_value = {'job_id': 'cli', 'status': 'running', 'started_at': 1}
contract = SimpleNamespace(db_path=db, library_processing_job=job, task_runs=runs)
with patch('peach.web_library_processing.settings_file.active', return_value=self.config):
self.assertNotIn('trigger', q_library_processing(contract, {}))
run = runs.start('library-processing', trigger='cli')
self.assertEqual(q_library_processing(contract, {})['trigger'], 'cli')
runs.finish(run.id, 'succeeded')
job.snapshot.return_value = {'job_id': 'cli', 'status': 'complete', 'started_at': 1}
self.assertNotIn('trigger', q_library_processing(contract, {}))
def test_web_pause_and_resume_preserve_running_job_and_initial_estimate(self):
from peach.jobs import BackgroundJob
from peach.library_processing import _save, state_path
from peach.web_library_processing import q_library_processing
from peach.repository import LedgerDatabase
from peach.task_runs import TaskRunStore
database_root = self.config.directory('database')
database_root.mkdir(parents=True)
db = fresh_ledger(database_root)
job = BackgroundJob('library-processing', runs=TaskRunStore(LedgerDatabase(db)))
self.addCleanup(job.stop, timeout=None)
contract = SimpleNamespace(db_path=db, library_processing_job=job,
task_runs=job.runs,
candidate_root=self.root / 'candidates', cover_root=self.root / 'covers',
database=job.runs.database, cache_bust=lambda: None)
entered, advance, done = (threading.Event() for _ in range(3))
calls, errors = [], []
def process(*_args, **kwargs):
identity = kwargs['job_id']
calls.append(identity)
def report(**values):
kwargs['report'](values)
try:
with control_for(self.config).bind(identity, publish=report, active=lambda: True, db_path=db):
_save(state_path(self.config), dict(job_id=identity, status='running', requested_stage='metadata'))
entered.set()
advance.wait(2)
checkpoint()
done.set()
return {'status': 'complete'}
except BaseException as exc:
errors.append(exc)
raise
with patch('peach.web_library_processing.settings_file.active', return_value=self.config), \
patch('peach.web_library_processing.process_library', side_effect=process):
initial = w_library_processing(contract, {'stage': 'metadata', 'speed_mode': 'high'})
self.assertIn('eta_seconds_max', initial)
self.assertEqual(initial['speed_mode'], 'high')
self.assertTrue(entered.wait(2))
run_id = initial['run_id']
identity = initial['job_id']
reply = w_library_processing(contract, {'action': 'pause', 'job_id': identity})
self.assertEqual(reply['status'], 'pausing')
advance.set()
self.assert_processing_status(contract, 'paused')
self.assertEqual(q_library_processing(contract, {})['status'], 'paused')
self.assertFalse(done.is_set())
w_library_processing(contract, {'action': 'resume', 'job_id': identity})
job.thread.join(2)
self.assertTrue(done.is_set())
self.assertEqual(job.snapshot()['status'], 'complete')
self.assertEqual(job.snapshot()['run_id'], run_id)
self.assertEqual(calls, [identity])
self.assertEqual(errors, [])
self.assertEqual(job.runs.get(run_id).status, 'succeeded')
def test_low_mode_ffmpeg_uses_single_decode_filter_and_encode_threads(self):
from peach.frame_capture import capture_frame
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='low'), \
patch('peach.frame_capture._readable_image', return_value=True), \
patch('peach.frame_capture.subprocess.run', return_value=SimpleNamespace(returncode=0, stderr=b'')) as run:
capture_frame('ffmpeg', 'video.mp4', 1, self.root / 'frame.jpg')
command = run.call_args.args[0]
self.assertEqual(command.count('-threads'), 2)
self.assertEqual(command[command.index('-filter_threads') + 1], '1')
def test_user_paused_job_keeps_identity_and_progress_through_service_restart(self):
from peach.jobs import BackgroundJob
from peach.library_processing import _save, state_path
from peach.repository import LedgerDatabase
from peach.task_runs import TaskRunStore
database_root = self.config.directory('database')
database_root.mkdir(parents=True)
db = fresh_ledger(database_root)
runs = TaskRunStore(LedgerDatabase(db))
first = BackgroundJob('library-processing', runs=runs)
self.addCleanup(first.stop, timeout=None)
contract = SimpleNamespace(db_path=db, library_processing_job=first,
task_runs=runs,
candidate_root=self.root / 'candidates', cover_root=self.root / 'covers',
database=runs.database, cache_bust=lambda: None)
entered, advance = (threading.Event() for _ in range(2))
identities, resumes = [], []
def process(*_args, **kwargs):
identity = kwargs['job_id']
identities.append(identity)
resumes.append(kwargs['resume_state'])
state = dict(job_id=identity, requested_stage='metadata', status='running', completed_metadata=['1:R:\\a.mp4'])
def report(**values):
values.pop('resumed_after', None)
state.update(values)
_save(state_path(self.config), state)
kwargs['report'](dict(state))
with control_for(self.config).bind(identity, publish=report, active=kwargs['active'], db_path=db):
_save(state_path(self.config), state)
if len(identities) == 1:
entered.set()
advance.wait(2)
try:
checkpoint()
except Exception:
from peach.processing_control import interrupted_state
state.update(interrupted_state(control_for(self.config)))
_save(state_path(self.config), state)
raise
return {'status': 'complete'}
with patch('peach.web_library_processing.settings_file.active', return_value=self.config), \
patch('peach.web_library_processing.process_library', side_effect=process):
initial = w_library_processing(contract, {'stage': 'metadata', 'speed_mode': 'high'})
self.assertTrue(entered.wait(2))
w_library_processing(contract, {'action': 'pause', 'job_id': initial['job_id']})
advance.set()
self.assert_processing_status(contract, 'paused')
from peach import task_control
task_control.request(runs, initial['run_id'], 'set_speed', mode='low')
first.stop()
self.assertEqual(runs.get(initial['run_id']).status, 'pending')
self.assertEqual(runs.get(initial['run_id']).progress_label, '已暂停,等待继续')
second = BackgroundJob('library-processing', runs=runs)
self.addCleanup(second.stop, timeout=None)
contract.library_processing_job = second
w_library_processing(contract, {'action': 'resume', 'job_id': initial['job_id']})
second.thread.join(2)
self.assertEqual(identities, [initial['job_id'], initial['job_id']])
self.assertEqual(resumes[1]['completed_metadata'], ['1:R:\\a.mp4'])
self.assertEqual(second.snapshot()['status'], 'complete')
self.assertEqual(second.snapshot()['run_id'], initial['run_id'])
self.assertEqual(control_for(self.config).mode, 'low')
self.assertEqual(task_control.speed_mode(db, initial['run_id']), 'low')
self.assertEqual(runs.get(initial['run_id']).status, 'succeeded')
def test_legacy_library_recovery_preserves_speed_without_a_task_speed_file(self):
from peach import task_control, update_tasks
from peach.jobs import BackgroundJob
from peach.library_processing import _save, state_path
from peach.task_runs import TaskRunStore
from peach.web_job_recovery import restore
from peach.web_library_processing import q_library_processing
from peach.processing_control import default_mode
database_root = self.config.directory('database')
database_root.mkdir(parents=True)
db = fresh_ledger(database_root)
runs = TaskRunStore(db)
for mode, source in (('low', 'state'), ('high', 'state'), ('high', 'control')):
with self.subTest(mode=mode, source=source):
identity = f'legacy-{mode}-{source}'
run = runs.start('library-processing', trigger='manual')
runs.suspend(run.id)
speed_file = update_tasks.state_for(db).parent / 'task-controls' / f'{run.id}.speed.json'
speed_file.unlink()
control = control_for(self.config)
control.select_job(identity)
control.request('set_speed', mode=mode if source == 'control' else 'standard')
previous = dict(job_id=identity, requested_stage='metadata', status='paused',
completed_metadata=['1:R:\\a.mp4'])
if source == 'state':
previous['speed_mode'] = mode
_save(state_path(self.config), previous)
job_recovery.save(db, 'library-processing', identity, {'stage': 'metadata', 'speed_mode': mode},
run_id=run.id, waiting=True)
job_recovery.checkpoint(db, 'library-processing', identity, previous)
job = BackgroundJob('library-processing', runs=runs)
self.addCleanup(job.stop, timeout=None)
contract = SimpleNamespace(db_path=db, library_processing_job=job, task_runs=runs,
database=runs.database, candidate_root=self.root / 'candidates',
cover_root=self.root / 'covers', cache_bust=lambda: None)
observed = []
def process(*_args, observed=observed, **kwargs):
observed.append((kwargs['speed_mode'], task_control.CURRENT.get().mode,
kwargs['resume_state']['completed_metadata']))
return {'status': 'complete'}
with patch('peach.web_library_processing.settings_file.active', return_value=self.config), \
patch('peach.web_library_processing.process_library', side_effect=process):
self.assertEqual(q_library_processing(contract, {})['speed_mode'], mode)
self.assertEqual(restore(contract), ['library-processing'])
job.thread.join(5)
self.assertFalse(job.thread.is_alive())
self.assertEqual(observed, [(mode, mode, ['1:R:\\a.mp4'])])
self.assertEqual(job.snapshot()['run_id'], run.id)
self.assertEqual(job.snapshot()['speed_mode'], mode)
self.assertEqual(runs.get(run.id).status, 'succeeded')
self.assertTrue(speed_file.exists())
self.assertEqual(task_control.speed_mode(db, run.id), mode)
self.assertEqual(default_mode(runs.database), 'standard')
def test_three_intensities_set_distinct_io_start_intervals(self):
control = control_for(self.config)
intervals = {}
for mode in ('low', 'standard', 'high'):
with self.subTest(mode=mode), \
control.bind(mode, publish=lambda **_: None, active=lambda: True, db_path=self.db, mode=mode), \
patch('peach.processing_control.time.monotonic', return_value=10):
checkpoint('network')
intervals[mode] = control.next_work['network'] - 10
self.assertGreater(intervals['low'], intervals['standard'])
self.assertGreater(intervals['standard'], intervals['high'])
self.assertEqual(intervals['high'], 0)
def test_three_intensities_set_decode_filter_and_encode_threads(self):
from peach.frame_capture import capture_frame, tile_frames
control = control_for(self.config)
for mode, threads in (('low', '1'), ('standard', '2'), ('high', None)):
with self.subTest(mode=mode), \
control.bind(mode, publish=lambda **_: None, active=lambda: True, db_path=self.db, mode=mode), \
patch('peach.frame_capture.checkpoint'), \
patch('peach.frame_capture._readable_image', return_value=True), \
patch('peach.frame_capture.subprocess.run', return_value=SimpleNamespace(returncode=0, stderr=b'')) as run:
capture_frame('ffmpeg', 'video.mp4', 1, self.root / 'frame.jpg')
tile_frames('ffmpeg', 'frame%02d.jpg', 3, 3, self.root / 'tile.jpg')
for call, filter_option in zip(run.call_args_list, ('-filter_threads', '-filter_complex_threads')):
command = call.args[0]
if threads is None:
self.assertNotIn('-threads', command)
self.assertNotIn(filter_option, command)
else:
self.assertEqual(command.count('-threads'), 2)
self.assertEqual(command[command.index(filter_option) + 1], threads)
self.assertEqual([command[index + 1] for index, value in enumerate(command)
if value == '-threads'], [threads, threads])
def test_intensity_switch_recalibrates_estimate_in_every_direction(self):
self.seed()
control = control_for(self.config)
for initial, target in (('low', 'standard'), ('standard', 'high'), ('high', 'low')):
with self.subTest(initial=initial, target=target), \
control.bind(initial, publish=lambda **_: None, active=lambda: True,
db_path=self.db, mode=initial):
state = dict(initial_estimate(self.config, self.db, 'metadata', mode=initial),
status='paused', requested_stage='metadata')
estimate = ProcessingEstimate(self.config, state, control)
old = state['eta_seconds_max']
control.request('set_speed', mode=target)
estimate.update(state)
self.assertEqual(state['speed_mode'], target)
if target == 'low':
self.assertGreater(state['eta_seconds_max'], old)
else:
self.assertLess(state['eta_seconds_max'], old)
def test_high_intensity_honors_installation_pause(self):
from peach.jobs import JobPaused
from peach import update_tasks
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='high'):
token = update_tasks.reserve(self.db)
try:
with self.assertRaises(JobPaused):
checkpoint('frame')
finally:
update_tasks.release(self.db, token)
checkpoint('frame')
self.assertEqual(control.mode, 'high')
def test_high_history_is_normalized_for_every_initial_intensity(self):
self.seed()
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='high'):
state = dict(initial_estimate(self.config, self.db, 'metadata', mode='high'),
requested_stage='metadata', status='running')
with patch('peach.processing_control.time.monotonic', return_value=100):
estimate = ProcessingEstimate(self.config, state, control)
state['status'] = 'complete'
with patch('peach.processing_control.time.monotonic', return_value=175):
estimate.update(state)
history = json.loads((self.config.directory('state') / 'library-processing-timing.json').read_text())
self.assertEqual(history['metadata'], 10.0)
low, standard, high = [initial_estimate(self.config, self.db, 'metadata', mode=mode)
for mode in ('low', 'standard', 'high')]
self.assertEqual(high['eta_kind'], 'historical')
self.assertGreater(low['eta_seconds_max'], standard['eta_seconds_max'])
self.assertGreater(standard['eta_seconds_max'], high['eta_seconds_max'])
def test_high_intensity_and_pause_intent_are_shared_between_control_instances(self):
control = control_for(self.config)
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='standard'):
external = ProcessingControl(control.path)
external.request('set_speed', 'one', mode='high')
external.request('pause', 'one')
control.refresh(force=True)
self.assertEqual(control.mode, 'high')
self.assertTrue(control.paused)
external.request('resume', 'one')
control.refresh(force=True)
self.assertFalse(control.paused)
self.assertEqual(control.mode, 'high')
def test_api_switches_between_all_intensities_with_proportional_paused_estimates(self):
from peach.jobs import BackgroundJob
control = control_for(self.config)
job = BackgroundJob('library-processing')
job.state = dict(job_id='one', status='paused', speed_mode='low', eta_seconds_min=180, eta_seconds_max=360)
from peach.task_runs import TaskRunStore
contract = SimpleNamespace(db_path=self.db, library_processing_job=job, task_runs=TaskRunStore(self.db))
with control.bind('one', publish=lambda **_: None, active=lambda: True, db_path=self.db, mode='low'), \
patch('peach.web_library_processing.settings_file.active', return_value=self.config):
control.request('pause', 'one')
high = w_library_processing(contract, {'action': 'set_speed', 'job_id': 'one', 'speed_mode': 'high'})
self.assertEqual((high['speed_mode'], high['status']), ('high', 'paused'))
self.assertEqual((high['eta_seconds_min'], high['eta_seconds_max']), (75, 150))
standard = w_library_processing(contract, {'action': 'set_speed', 'job_id': 'one', 'speed_mode': 'standard'})
self.assertEqual((standard['eta_seconds_min'], standard['eta_seconds_max']), (100, 200))
low = w_library_processing(contract, {'action': 'set_speed', 'job_id': 'one', 'speed_mode': 'low'})
self.assertEqual((low['eta_seconds_min'], low['eta_seconds_max']), (180, 360))
self.assertTrue(control.paused)
def test_library_and_task_controls_share_runtime_speed_without_changing_the_default(self):
from peach import task_control
from peach.jobs import BackgroundJob
from peach.task_runs import TaskRunStore
from peach.web_library_processing import q_library_processing
from peach.processing_control import default_mode
runs = TaskRunStore(self.db)
run = runs.start('library-processing', trigger='manual')
job = BackgroundJob('library-processing')
job.state = dict(job_id='one', run_id=run.id, status='running', speed_mode='standard',
eta_seconds_min=100, eta_seconds_max=200)
contract = SimpleNamespace(db_path=self.db, library_processing_job=job, task_runs=runs)
control = control_for(self.config)
with patch('peach.web_library_processing.settings_file.active', return_value=self.config):
task_control.request(runs, run.id, 'set_speed', mode='high')
state = q_library_processing(contract, {})
self.assertEqual((state['speed_mode'], state['eta_seconds_min']), ('high', 75))
state = w_library_processing(contract, {'action': 'set_speed', 'job_id': 'one', 'speed_mode': 'low'})
self.assertEqual(state['speed_mode'], 'low')
self.assertEqual(task_control.projection(runs, run)['speed_mode'], 'low')
self.assertEqual(default_mode(runs.database), 'standard')
with task_control.bind(runs, run.id), control.bind(
'one', publish=lambda **_: None, active=lambda: True, db_path=self.db):
self.assertEqual(control.mode, 'low')
task_control.request(runs, run.id, 'set_speed', mode='high')
self.assertEqual(control.mode, 'high')
def test_new_library_run_uses_settings_instead_of_the_previous_processing_mode(self):
from peach.jobs import BackgroundJob
from peach.task_runs import TaskRunStore
from peach.web_settings import w_settings
root = self.config.directory('database')
root.mkdir(parents=True)
db = fresh_ledger(root)
runs = TaskRunStore(db)
w_settings(runs.database, {'taskSpeedMode': 'low'})
control_for(self.config).request('set_speed', mode='high')
job = BackgroundJob('library-processing', runs=runs)
contract = SimpleNamespace(db_path=db, database=runs.database, task_runs=runs,
library_processing_job=job)
with patch('peach.web_library_processing.settings_file.active', return_value=self.config), \
patch.object(job, 'start', side_effect=lambda _work, **kwargs: kwargs['initial']) as start:
result = w_library_processing(contract, {'stage': 'metadata'})
self.assertEqual(result['speed_mode'], 'low')
self.assertEqual(start.call_args.kwargs['recovery']['speed_mode'], 'low')
chosen = w_library_processing(contract, {'action': 'set_speed', 'speed_mode': 'high'})
self.assertEqual(chosen['speed_mode'], 'high')
from peach.processing_control import default_mode
self.assertEqual(default_mode(runs.database), 'low')
selected = w_library_processing(contract, {'stage': 'metadata'})
self.assertEqual(selected['speed_mode'], 'high')
from peach.web_library_processing import q_library_processing
self.assertEqual(q_library_processing(contract, {})['speed_mode'], 'low')
def test_scan_progress_accumulates_discovered_counts_across_roots(self):
from peach.library_processing import _scan_sources
reports = []
config = PeachConfig(self.root, self.root / 'config.toml', present=True,
locations={'local': ('R:\\Media', 'R:\\Other')})
def scan(*_args, **kwargs):
kwargs['progress'](1, 10)
kwargs['progress'](2, 30)
return SimpleNamespace(files=2, total_bytes=30, new_ids=(), junk_isolated=1)
with patch('peach.library_processing.scan_location', side_effect=scan), \
patch('peach.library_processing._online_source_root', return_value=self.root), \
patch('peach.library_processing._ffprobe_path', return_value=None), \
patch('peach.library_processing.probe_unmeasured', return_value=0), \
patch('peach.library_processing.reattach_in'):
result = _scan_sources(config, self.db, locations=config.locations, asset_ids=None,
stage='scan', guard=Mock(), update=lambda **values: reports.append(values),
issue=lambda *_args, **_kwargs: None)
discovering = [row for row in reports if row.get('current_action') == 'scanning_files']
self.assertEqual([row['scanned'] for row in discovering], [1, 2, 3, 4])
self.assertEqual([row['scanned_bytes'] for row in discovering], [10, 30, 40, 60])
self.assertTrue(all(row['total'] == 0 for row in discovering))
self.assertEqual((result.scanned, result.scanned_bytes), (4, 60))
self.assertEqual(result.junk_isolated, 2)
def test_incremental_scan_probes_rows_moved_onto_new_files(self):
"""按番号改指到新文件的旧行不算新行,但文件换了,增量扫描照样要探它。"""
from peach.library_processing import _scan_sources
config = PeachConfig(self.root, self.root / 'config.toml', present=True, locations={'local': ('R:\\Media',)})
scanned = SimpleNamespace(files=2, total_bytes=30, new_ids=(5,), relocated_ids=(7,), junk_isolated=0)
with patch('peach.library_processing.scan_location', return_value=scanned), \
patch('peach.library_processing._online_source_root', return_value=self.root), \
patch('peach.library_processing._ffprobe_path', return_value=None), \
patch('peach.library_processing.probe_unmeasured', return_value=0) as probe, \
patch('peach.library_processing.reattach_in') as reattach:
result = _scan_sources(config, self.db, locations=config.locations, asset_ids=None,
stage='scan', guard=Mock(), update=lambda **values: None,
issue=lambda *_args, **_kwargs: None, incremental=True)
self.assertEqual(probe.call_args.kwargs['asset_ids'], [5, 7])
self.assertEqual(result.new_ids, (5,), "改指的旧行不进新影片范围")
reattach.assert_called_once_with(self.db, (5,))
def test_library_scan_reports_conflicting_records_and_processes_both_roots(self):
import sqlite3
from peach.library_processing import _scan_sources
first, second = self.root / 'first', self.root / 'second'
first.mkdir()
second.mkdir()
(first / 'copy.mp4').write_bytes(b'copy')
(first / 'new.mp4').write_bytes(b'new')
(second / 'other.mp4').write_bytes(b'other')
config = PeachConfig(self.root, self.root / 'config.toml', present=True,
locations={'local': ('R:\\Media', 'S:\\Other')},
mounts={'local': (str(first), str(second))})
with closing(sqlite3.connect(self.db)) as connection, connection:
for path in ('R:\\Media\\copy.mp4', 'r:\\MEDIA\\COPY.MP4'):
connection.execute("INSERT INTO asset(location,path,name,medium) "
"VALUES('local',?,'copy.mp4','video')", (path,))
issues = []
with patch('peach.library_processing._online_source_root', return_value=first), \
patch('peach.library_processing._ffprobe_path', return_value=None), \
patch('peach.library_processing.probe_unmeasured', return_value=0):
result = _scan_sources(config, self.db, locations=config.locations, asset_ids=None,
stage='scan', guard=Mock(), update=lambda **values: None,
issue=lambda row, message, **values: issues.append((message, values)))
self.assertEqual((result.scanned, result.scanned_bytes), (3, 12))
self.assertEqual(len(issues), 1)
self.assertIn('记录 1、2', issues[0][0])
self.assertEqual(issues[0][1]['action'], 'scanning_files')
with closing(sqlite3.connect(self.db)) as connection:
self.assertEqual(connection.execute('SELECT count(*) FROM asset').fetchone()[0], 4)
self.assertIsNotNone(connection.execute("SELECT id FROM asset WHERE path='S:\\Other\\other.mp4'").fetchone())