feat: add atomic state store and legacy migration
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from controller.config import StorageConfig
|
||||
from controller.models import MigrationError, SlotProtectedError
|
||||
from controller.store import ControllerStore, LEGACY_MIGRATION_NAME
|
||||
|
||||
|
||||
class MutableClock:
|
||||
def __init__(self, value=1_700_000_000.0):
|
||||
self.value = value
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
self.base = Path(self.tempdir.name)
|
||||
self.media = self.base / "media"
|
||||
self.data = self.base / "data"
|
||||
self.clock = MutableClock()
|
||||
self.config = StorageConfig(
|
||||
media_base=self.media,
|
||||
data_base=self.data,
|
||||
retention_seconds=100,
|
||||
uncertain_review_after_seconds=30,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def open_store(self):
|
||||
return ControllerStore(self.config, now=self.clock).open()
|
||||
|
||||
|
||||
class FreshStoreTests(StoreTestCase):
|
||||
def test_fresh_database_seeds_exact_stable_slots(self):
|
||||
with self.open_store() as store:
|
||||
slots = store.list_slots()
|
||||
self.assertEqual([slot.slot for slot in slots], list(range(1, 11)))
|
||||
self.assertTrue(all(slot.media_state == "empty" for slot in slots))
|
||||
self.assertEqual(store.player_observation().phase, "unknown")
|
||||
|
||||
def test_slot_reservation_is_committed_before_worker_transition(self):
|
||||
with self.open_store() as store:
|
||||
reservation = store.reserve_download(
|
||||
"https://www.youtube.com/playlist?list=one",
|
||||
"Alice",
|
||||
"Service",
|
||||
job_id="job-one",
|
||||
)
|
||||
self.assertEqual(reservation.status, "reserved")
|
||||
self.assertEqual(reservation.slot, 1)
|
||||
self.assertEqual(store.get_slot(1).media_state, "queued")
|
||||
job = store.get_job("job-one")
|
||||
self.assertIsNotNone(job)
|
||||
self.assertEqual(job.status, "reserved")
|
||||
self.assertTrue(store.mark_download_running("job-one", 1, 1))
|
||||
self.assertEqual(store.get_slot(1).media_state, "downloading")
|
||||
|
||||
def test_concurrent_requests_get_only_one_remaining_slot(self):
|
||||
with self.open_store() as store:
|
||||
for index in range(1, 10):
|
||||
result = store.reserve_download(
|
||||
f"https://example.test/{index}",
|
||||
None,
|
||||
None,
|
||||
job_id=f"seed-{index}",
|
||||
)
|
||||
self.assertEqual(result.slot, index)
|
||||
|
||||
def reserve(index):
|
||||
return store.reserve_download(
|
||||
f"https://example.test/concurrent-{index}",
|
||||
None,
|
||||
None,
|
||||
job_id=f"concurrent-{index}",
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(reserve, range(8)))
|
||||
winners = [result for result in results if result.status == "reserved"]
|
||||
self.assertEqual(len(winners), 1)
|
||||
self.assertEqual(winners[0].slot, 10)
|
||||
self.assertEqual(
|
||||
sum(result.status == "queue_full" for result in results),
|
||||
7,
|
||||
)
|
||||
|
||||
def test_stale_worker_cannot_overwrite_reused_slot(self):
|
||||
with self.open_store() as store:
|
||||
first = store.reserve_download("https://example.test/one", None, None, job_id="old")
|
||||
store.mark_download_running("old", 1, first.generation)
|
||||
store.fail_download("old", 1, first.generation, "failed")
|
||||
deletion = store.begin_delete(1, reason="test")
|
||||
self.assertTrue(store.finish_delete(deletion))
|
||||
second = store.reserve_download("https://example.test/two", None, None, job_id="new")
|
||||
self.assertEqual(second.slot, 1)
|
||||
self.assertFalse(
|
||||
store.complete_download(
|
||||
"old",
|
||||
1,
|
||||
first.generation,
|
||||
title="stale",
|
||||
items=(("Stale", "stale.mp4"),),
|
||||
)
|
||||
)
|
||||
self.assertEqual(store.get_slot(1).url, "https://example.test/two")
|
||||
|
||||
def test_deleting_slot_does_not_renumber_other_slots(self):
|
||||
with self.open_store() as store:
|
||||
first = store.reserve_download("https://example.test/one", None, None, job_id="one")
|
||||
second = store.reserve_download("https://example.test/two", None, None, job_id="two")
|
||||
store.fail_download("one", 1, first.generation, "failed")
|
||||
store.fail_download("two", 2, second.generation, "failed")
|
||||
token = store.begin_delete(1, reason="manual")
|
||||
store.finish_delete(token)
|
||||
self.assertEqual(store.get_slot(1).media_state, "empty")
|
||||
self.assertEqual(store.get_slot(2).slot, 2)
|
||||
self.assertEqual(store.get_slot(2).url, "https://example.test/two")
|
||||
|
||||
|
||||
class GuardAndExpiryTests(StoreTestCase):
|
||||
def _ready_slot(self, store, *, slot_expected=1):
|
||||
reservation = store.reserve_download("https://example.test/ready", None, None, job_id="ready")
|
||||
self.assertEqual(reservation.slot, slot_expected)
|
||||
store.mark_download_running("ready", reservation.slot, reservation.generation)
|
||||
store.complete_download(
|
||||
"ready",
|
||||
reservation.slot,
|
||||
reservation.generation,
|
||||
title="Ready",
|
||||
items=(("One", "one.mp4"),),
|
||||
)
|
||||
return reservation
|
||||
|
||||
def test_active_and_review_guards_block_delete_and_expiry(self):
|
||||
with self.open_store() as store:
|
||||
reservation = self._ready_slot(store)
|
||||
guard = store.protect_slot(
|
||||
1,
|
||||
"restart_uncertain",
|
||||
guard_id="review-me",
|
||||
review_after=self.clock.value + 10,
|
||||
)
|
||||
self.clock.value += 200
|
||||
due = store.mark_due_guards_for_review()
|
||||
self.assertEqual(due[0].state, "review_required")
|
||||
with self.assertRaises(SlotProtectedError):
|
||||
store.begin_delete(1, reason="manual")
|
||||
self.assertEqual(store.begin_expired_deletions(), [])
|
||||
self.assertTrue(
|
||||
store.clear_guard(
|
||||
1,
|
||||
guard.guard_id,
|
||||
actor="operator",
|
||||
reason="Player host replaced",
|
||||
)
|
||||
)
|
||||
self.assertEqual(store.player_observation().phase, "unknown")
|
||||
expired = store.begin_expired_deletions()
|
||||
self.assertEqual([token.slot for token in expired], [reservation.slot])
|
||||
|
||||
def test_admin_clear_requires_current_token_actor_and_reason(self):
|
||||
with self.open_store() as store:
|
||||
self._ready_slot(store)
|
||||
guard = store.protect_slot(1, "player_active", guard_id="current")
|
||||
with self.assertRaises(ValueError):
|
||||
store.clear_guard(1, guard.guard_id, actor="", reason="because")
|
||||
self.assertFalse(
|
||||
store.clear_guard(
|
||||
1,
|
||||
"stale-token",
|
||||
actor="operator",
|
||||
reason="confirmed",
|
||||
)
|
||||
)
|
||||
self.assertEqual(store.list_live_guards()[0].guard_id, "current")
|
||||
|
||||
def test_restart_marks_last_active_slot_uncertain_without_claiming_idle(self):
|
||||
with self.open_store() as store:
|
||||
self._ready_slot(store)
|
||||
store.set_player_observation(
|
||||
availability="online",
|
||||
phase="playing",
|
||||
active_slot=1,
|
||||
active_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
paused=False,
|
||||
reconciliation_state="confirmed",
|
||||
reconciliation_due_at=None,
|
||||
)
|
||||
guard = store.mark_restart_uncertain()
|
||||
self.assertEqual(guard.slot, 1)
|
||||
observation = store.player_observation()
|
||||
self.assertEqual(observation.phase, "unknown")
|
||||
self.assertEqual(observation.reconciliation_state, "awaiting_evidence")
|
||||
self.assertEqual(observation.active_slot, 1)
|
||||
|
||||
|
||||
class LegacyMigrationTests(StoreTestCase):
|
||||
def write_legacy(self, data):
|
||||
self.data.mkdir(parents=True, exist_ok=True)
|
||||
raw = yaml.safe_dump(data, sort_keys=True).encode("utf-8")
|
||||
self.config.legacy_yaml_path.write_bytes(raw)
|
||||
return raw
|
||||
|
||||
def test_migration_preserves_holes_timestamps_original_and_backup(self):
|
||||
raw = self.write_legacy(
|
||||
{
|
||||
"slots": {
|
||||
"2": {
|
||||
"slot": 2,
|
||||
"url": "https://example.test/two",
|
||||
"yt_id": "abc",
|
||||
"title": "Two",
|
||||
"path": "/unsafe/ignored",
|
||||
"m3u": "/unsafe/ignored/playlist.m3u",
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
"expires_at": "2025-01-04T03:04:05Z",
|
||||
"item_count": 3,
|
||||
"state": "playing",
|
||||
},
|
||||
"7": {
|
||||
"slot": 7,
|
||||
"url": "https://example.test/seven",
|
||||
"title": "Seven",
|
||||
"path": "/unsafe/seven",
|
||||
"m3u": "/unsafe/seven/playlist.m3u",
|
||||
"created_at": "2025-02-01T00:00:00Z",
|
||||
"expires_at": "2025-02-03T00:00:00Z",
|
||||
"item_count": 1,
|
||||
"state": "ready",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
with self.open_store() as store:
|
||||
self.assertTrue(store.migration_report.migrated)
|
||||
self.assertEqual(store.migration_report.source_sha256, digest)
|
||||
self.assertEqual(store.get_slot(1).media_state, "empty")
|
||||
self.assertEqual(store.get_slot(2).title, "Two")
|
||||
self.assertEqual(store.get_slot(7).slot, 7)
|
||||
self.assertEqual(store.get_slot(2).created_at, 1_735_787_045.0)
|
||||
guards = store.list_live_guards()
|
||||
self.assertEqual([(guard.slot, guard.state) for guard in guards], [(2, "review_required")])
|
||||
marker = store.connection.execute(
|
||||
"SELECT * FROM data_migrations WHERE name = ?",
|
||||
(LEGACY_MIGRATION_NAME,),
|
||||
).fetchone()
|
||||
self.assertEqual(marker["source_sha256"], digest)
|
||||
backup = Path(marker["backup_path"])
|
||||
self.assertEqual(backup.read_bytes(), raw)
|
||||
self.assertEqual(self.config.legacy_yaml_path.read_bytes(), raw)
|
||||
|
||||
def test_completed_migration_is_idempotent_and_ignores_changed_yaml(self):
|
||||
original = self.write_legacy(
|
||||
{
|
||||
"slots": {
|
||||
"3": {
|
||||
"slot": 3,
|
||||
"url": "https://example.test/original",
|
||||
"title": "Original",
|
||||
"path": "/ignored",
|
||||
"m3u": "/ignored/playlist.m3u",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"expires_at": "2025-01-03T00:00:00Z",
|
||||
"item_count": 1,
|
||||
"state": "ready",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
with self.open_store() as store:
|
||||
generation = store.get_slot(3).generation
|
||||
backup_path = Path(store.migration_report.backup_path)
|
||||
self.config.legacy_yaml_path.write_text("not: [the, original]\n", encoding="utf-8")
|
||||
with self.open_store() as store:
|
||||
self.assertFalse(store.migration_report.migrated)
|
||||
self.assertEqual(store.get_slot(3).title, "Original")
|
||||
self.assertEqual(store.get_slot(3).generation, generation)
|
||||
self.assertEqual(backup_path.read_bytes(), original)
|
||||
|
||||
def test_failed_migration_keeps_original_and_verified_backup_recoverable(self):
|
||||
self.data.mkdir(parents=True, exist_ok=True)
|
||||
raw = b"slots:\n 1: [truncated\n"
|
||||
self.config.legacy_yaml_path.write_bytes(raw)
|
||||
store = ControllerStore(self.config, now=self.clock)
|
||||
with self.assertRaises(MigrationError):
|
||||
store.open()
|
||||
self.assertFalse(self.config.database_path.exists())
|
||||
self.assertEqual(self.config.legacy_yaml_path.read_bytes(), raw)
|
||||
backups = list(self.data.glob("playlists.yaml.pre-v2.*.bak"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(backups[0].read_bytes(), raw)
|
||||
|
||||
def test_normalized_duplicate_slot_keys_fail_all_or_nothing(self):
|
||||
self.data.mkdir(parents=True, exist_ok=True)
|
||||
raw = b"slots:\n 1:\n slot: 1\n '01':\n slot: 1\n"
|
||||
self.config.legacy_yaml_path.write_bytes(raw)
|
||||
with self.assertRaisesRegex(MigrationError, "normalize to 1"):
|
||||
self.open_store()
|
||||
self.assertFalse(self.config.database_path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user