- POST /restore/preview stages an upload in the library's folder and answers what it holds beside the live library, writing nothing live; DELETE discards it; at most two stages are held (429 too-large). - POST /restore/apply rechecks the live fingerprint (409 plan-changed) and starts "Database restore": exclusive, not cancellable, indeterminate. It waits for request leases, publishes the exact staged bytes, swaps the database, prunes safety backups and logs counts, repairs and the backup. - The legacy /restore runs the same stage and publish as an exclusive hold with its old response, so a restore and the health check, Move library and every other job refuse each other. - JobRegistry.lease: Needs review decisions and maintenance and restore previews are refused while an exclusive job runs, and a restore waits for them before it switches the file. - GET /backup holds a guarded slot, verifies a standalone snapshot before streaming it and removes the closed temp file; GET /backup/status lists the safety backups on disk and this session's verified download.
101 lines
3.7 KiB
Python
101 lines
3.7 KiB
Python
"""Registry leases: short request-scoped work versus a database switch (#249 S8).
|
|
|
|
A lease is refused while an ``exclusive`` job (restore, Move library,
|
|
Optimize) runs, never refuses another lease or any job, is not listed, and an
|
|
exclusive job can await the leases already running. Ordering is driven by
|
|
events only.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from py.api.jobs import JobBusy, JobRegistry # noqa: E402
|
|
|
|
|
|
class TestLeases(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
self.registry = JobRegistry()
|
|
|
|
def start(self, kind, admission):
|
|
gate = asyncio.Event()
|
|
|
|
async def runner(job):
|
|
await gate.wait()
|
|
|
|
job = self.registry.start(
|
|
kind, kind.title(), admission=admission, scope={}, runner=runner
|
|
)
|
|
return job, gate
|
|
|
|
async def test_leases_share_and_are_not_listed(self):
|
|
scan, gate = self.start("scan", "records")
|
|
async with self.registry.lease("Needs review decision"):
|
|
async with self.registry.lease("Needs review decision"):
|
|
self.assertEqual(self.registry.leases, {"Needs review decision": 2})
|
|
# A lease never refuses a job, even an exclusive one.
|
|
self.assertIsNone(self.registry.blocker("health", admission="read"))
|
|
self.assertEqual(self.registry.leases, {})
|
|
self.assertEqual([job.kind for job in self.registry.list()], ["scan"])
|
|
gate.set()
|
|
await scan.task
|
|
|
|
async def test_lease_refused_while_exclusive_job_runs(self):
|
|
restore, gate = self.start("restore", "exclusive")
|
|
with self.assertRaises(JobBusy) as refused:
|
|
async with self.registry.lease("Maintenance preview"):
|
|
self.fail("admitted beside a restore")
|
|
self.assertIs(refused.exception.running, restore)
|
|
self.assertEqual(self.registry.leases, {})
|
|
gate.set()
|
|
await restore.task
|
|
async with self.registry.lease("Maintenance preview"):
|
|
pass
|
|
|
|
async def test_exclusive_job_awaits_running_leases(self):
|
|
entered, release = asyncio.Event(), asyncio.Event()
|
|
order = []
|
|
|
|
async def decision():
|
|
async with self.registry.lease("Needs review decision"):
|
|
entered.set()
|
|
await release.wait()
|
|
order.append("decision done")
|
|
|
|
task = asyncio.ensure_future(decision())
|
|
await entered.wait()
|
|
drained = asyncio.ensure_future(self.registry.drain_leases())
|
|
await asyncio.sleep(0)
|
|
self.assertFalse(drained.done())
|
|
release.set()
|
|
self.assertTrue(await drained)
|
|
order.append("drained")
|
|
await task
|
|
self.assertEqual(order, ["decision done", "drained"])
|
|
self.assertTrue(await self.registry.drain_leases()) # none: at once
|
|
|
|
async def test_drain_reports_a_lease_that_outlives_the_ceiling(self):
|
|
hold = self.registry.lease("Stuck preview")
|
|
await hold.__aenter__()
|
|
try:
|
|
with self.assertLogs("py.api.jobs", "WARNING") as logs:
|
|
self.assertFalse(await self.registry.drain_leases(timeout=0))
|
|
self.assertIn("Stuck preview", "\n".join(logs.output))
|
|
self.assertEqual(self.registry._drain_waiters, [])
|
|
finally:
|
|
await hold.__aexit__(None, None, None)
|
|
self.assertEqual(self.registry.leases, {})
|
|
|
|
async def test_a_failed_body_still_releases_the_lease(self):
|
|
with self.assertRaises(RuntimeError):
|
|
async with self.registry.lease("Needs review decision"):
|
|
raise RuntimeError("boom")
|
|
self.assertEqual(self.registry.leases, {})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|