Merge pull request #391 from mcdonc/perf/event-driven-worker-wakeup
perf: event-driven worker wakeup via asyncio.Condition
This commit is contained in:
commit
fda798a746
3 changed files with 59 additions and 1 deletions
|
|
@ -66,6 +66,9 @@ class JobRepo:
|
||||||
# JobRepo and SyncStateRepo share the same connection, callers must
|
# JobRepo and SyncStateRepo share the same connection, callers must
|
||||||
# pass the same lock instance so cross-repo calls also serialize.
|
# pass the same lock instance so cross-repo calls also serialize.
|
||||||
self._lock = lock or asyncio.Lock()
|
self._lock = lock or asyncio.Lock()
|
||||||
|
# Notified after a successful enqueue so workers can wake
|
||||||
|
# immediately instead of polling on a fixed sleep interval.
|
||||||
|
self.job_available = asyncio.Condition()
|
||||||
|
|
||||||
async def enqueue(
|
async def enqueue(
|
||||||
self,
|
self,
|
||||||
|
|
@ -110,6 +113,9 @@ class JobRepo:
|
||||||
) as cursor:
|
) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
await self._conn.commit()
|
await self._conn.commit()
|
||||||
|
if row is not None:
|
||||||
|
async with self.job_available:
|
||||||
|
self.job_available.notify_all()
|
||||||
return _row_to_job(row) if row else None
|
return _row_to_job(row) if row else None
|
||||||
|
|
||||||
async def claim_next(self, worker_id: str) -> Job | None:
|
async def claim_next(self, worker_id: str) -> Job | None:
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ class WorkerPool:
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
# Wake workers parked on job_available.wait() so they notice _stop
|
||||||
|
# immediately instead of sleeping out the full poll_idle interval.
|
||||||
|
async with self._jobs.job_available:
|
||||||
|
self._jobs.job_available.notify_all()
|
||||||
tasks = list(self._workers)
|
tasks = list(self._workers)
|
||||||
if self._reaper is not None:
|
if self._reaper is not None:
|
||||||
tasks.append(self._reaper)
|
tasks.append(self._reaper)
|
||||||
|
|
@ -130,7 +134,14 @@ class WorkerPool:
|
||||||
continue
|
continue
|
||||||
job = await self._jobs.claim_next(worker_id)
|
job = await self._jobs.claim_next(worker_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
await self._sleep_or_stop(self._poll_idle_s)
|
try:
|
||||||
|
async with self._jobs.job_available:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._jobs.job_available.wait(),
|
||||||
|
timeout=self._poll_idle_s,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
continue
|
continue
|
||||||
await self._process(job)
|
await self._process(job)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,47 @@ def _pool(client, jobs, sync, **kwargs) -> WorkerPool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- event-driven wakeup ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_worker_picks_up_job_quickly(client, jobs, sync):
|
||||||
|
"""An idle worker should wake up well under poll_idle_s when a job is
|
||||||
|
enqueued, thanks to the job_available condition notification."""
|
||||||
|
client.create_document_from_source.return_value = Document(
|
||||||
|
id="d", content="x", uri="u", metadata={"md5": "m", "source_revision": "r"}
|
||||||
|
)
|
||||||
|
pool = _pool(client, jobs, sync, worker_count=1, poll_idle_interval_s=5.0)
|
||||||
|
await pool.start()
|
||||||
|
try:
|
||||||
|
await jobs.enqueue("src", "u", JobOp.UPSERT)
|
||||||
|
for _ in range(50):
|
||||||
|
listed = await jobs.list_jobs(status=JobStatus.SUCCEEDED)
|
||||||
|
if listed:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert len(listed) == 1, "job was not picked up within 2.5s"
|
||||||
|
finally:
|
||||||
|
await pool.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_completes_with_idle_workers(client, jobs, sync):
|
||||||
|
"""stop() must notify workers parked on job_available.wait() so they
|
||||||
|
exit promptly. Without the notify, workers sleep for the full
|
||||||
|
poll_idle_interval_s before noticing _stop."""
|
||||||
|
pool = _pool(client, jobs, sync, worker_count=2, poll_idle_interval_s=10.0)
|
||||||
|
await pool.start()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(pool.stop(), timeout=2.0)
|
||||||
|
except TimeoutError:
|
||||||
|
pytest.fail(
|
||||||
|
"stop() did not complete within 2s — idle workers were not woken"
|
||||||
|
)
|
||||||
|
assert pool.live_workers == 0
|
||||||
|
|
||||||
|
|
||||||
# --- drain_once: covers _process logic deterministically ---
|
# --- drain_once: covers _process logic deterministically ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue