Skip to content

Commit fdea3d1

Browse files
authored
fix: Improve AutoscaledPool state management (#241)
- closes #236
1 parent 671f54b commit fdea3d1

1 file changed

Lines changed: 48 additions & 37 deletions

File tree

src/crawlee/autoscaling/autoscaled_pool.py

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
if TYPE_CHECKING:
1515
from crawlee.autoscaling import SystemStatus
1616

17+
__all__ = ['ConcurrencySettings', 'AutoscaledPool']
18+
1719
logger = getLogger(__name__)
1820

1921

@@ -63,6 +65,16 @@ def __init__(
6365
self.max_tasks_per_minute = max_tasks_per_minute
6466

6567

68+
class _AutoscaledPoolRun:
69+
def __init__(self) -> None:
70+
self.worker_tasks = list[asyncio.Task]()
71+
"""A list of worker tasks currently in progress"""
72+
73+
self.worker_tasks_updated = asyncio.Event()
74+
self.cleanup_done = asyncio.Event()
75+
self.result: asyncio.Future = asyncio.Future()
76+
77+
6678
class AutoscaledPool:
6779
"""Manages a pool of asynchronous resource-intensive tasks that are executed in parallel.
6880
@@ -131,13 +143,6 @@ def __init__(
131143

132144
self._autoscale_task = RecurringTask(self._autoscale, autoscale_interval)
133145

134-
self._worker_tasks = list[asyncio.Task]()
135-
"""A list of worker tasks currently in progress"""
136-
137-
self._worker_tasks_updated = asyncio.Event()
138-
self._cleanup_done = asyncio.Event()
139-
self._run_result: asyncio.Future = asyncio.Future()
140-
141146
if desired_concurrency_ratio < 0 or desired_concurrency_ratio > 1:
142147
raise ValueError('desired_concurrency_ratio must be between 0 and 1 (non-inclusive)')
143148

@@ -154,32 +159,33 @@ def __init__(
154159

155160
self._max_tasks_per_minute = concurrency_settings.max_tasks_per_minute
156161
self._is_paused = False
157-
self._is_running = False
162+
self._current_run: _AutoscaledPoolRun | None = None
158163

159164
async def run(self) -> None:
160165
"""Start the autoscaled pool and return when all tasks are completed and `is_finished_function` returns True.
161166
162167
If there is an exception in one of the tasks, it will be re-raised.
163168
"""
164-
if self._is_running:
169+
if self._current_run is not None:
165170
raise RuntimeError('The pool is already running')
166171

167-
self._is_running = True
168-
self._cleanup_done.clear()
172+
run = _AutoscaledPoolRun()
173+
self._current_run = run
174+
169175
logger.debug('Starting the pool')
170176

171177
self._autoscale_task.start()
172178
self._log_system_status_task.start()
173179

174180
orchestrator = asyncio.create_task(
175-
self._worker_task_orchestrator(), name='autoscaled pool worker task orchestrator'
181+
self._worker_task_orchestrator(run), name='autoscaled pool worker task orchestrator'
176182
)
177183

178184
try:
179-
await self._run_result
185+
await run.result
180186
except AbortError:
181187
orchestrator.cancel()
182-
for task in self._worker_tasks:
188+
for task in run.worker_tasks:
183189
if not task.done():
184190
task.cancel()
185191
finally:
@@ -195,21 +201,23 @@ async def run(self) -> None:
195201

196202
logger.info('Waiting for remaining tasks to finish')
197203

198-
for task in self._worker_tasks:
204+
for task in run.worker_tasks:
199205
if not task.done():
200206
with suppress(BaseException):
201207
await task
202208

203-
self._run_result = asyncio.Future()
204-
self._cleanup_done.set()
205-
self._is_running = False
209+
run.cleanup_done.set()
210+
self._current_run = None
206211

207212
logger.debug('Pool cleanup finished')
208213

209214
async def abort(self) -> None:
210215
"""Interrupt the autoscaled pool and all the tasks in progress."""
211-
self._run_result.set_exception(AbortError())
212-
await self._cleanup_done.wait()
216+
if not self._current_run:
217+
raise RuntimeError('The pool is not running')
218+
219+
self._current_run.result.set_exception(AbortError())
220+
await self._current_run.cleanup_done.wait()
213221

214222
def pause(self) -> None:
215223
"""Pause the autoscaled pool so that it does not start new tasks."""
@@ -227,7 +235,10 @@ def desired_concurrency(self) -> int:
227235
@property
228236
def current_concurrency(self) -> int:
229237
"""The number of concurrent tasks in progress."""
230-
return len(self._worker_tasks)
238+
if self._current_run is None:
239+
return 0
240+
241+
return len(self._current_run.worker_tasks)
231242

232243
def _autoscale(self) -> None:
233244
"""Inspect system load status and adjust desired concurrency if necessary. Do not call directly."""
@@ -258,16 +269,16 @@ def _log_system_status(self) -> None:
258269
f'{system_status!s}'
259270
)
260271

261-
async def _worker_task_orchestrator(self) -> None:
272+
async def _worker_task_orchestrator(self, run: _AutoscaledPoolRun) -> None:
262273
"""Launches worker tasks whenever there is free capacity and a task is ready.
263274
264275
Exits when `is_finished_function` returns True.
265276
"""
266277
finished = False
267278

268279
try:
269-
while not (finished := await self._is_finished_function()) and not self._run_result.done():
270-
self._worker_tasks_updated.clear()
280+
while not (finished := await self._is_finished_function()) and not run.result.done():
281+
run.worker_tasks_updated.clear()
271282

272283
current_status = self._system_status.get_current_system_info()
273284
if not current_status.is_system_idle:
@@ -281,44 +292,44 @@ async def _worker_task_orchestrator(self) -> None:
281292
else:
282293
logger.debug('Scheduling a new task')
283294
worker_task = asyncio.create_task(self._worker_task(), name='autoscaled pool worker task')
284-
worker_task.add_done_callback(lambda task: self._reap_worker_task(task))
285-
self._worker_tasks.append(worker_task)
295+
worker_task.add_done_callback(lambda task: self._reap_worker_task(task, run))
296+
run.worker_tasks.append(worker_task)
286297

287298
if math.isfinite(self._max_tasks_per_minute):
288299
await asyncio.sleep(60 / self._max_tasks_per_minute)
289300

290301
continue
291302

292303
with suppress(asyncio.TimeoutError):
293-
await asyncio.wait_for(self._worker_tasks_updated.wait(), timeout=0.5)
304+
await asyncio.wait_for(run.worker_tasks_updated.wait(), timeout=0.5)
294305
finally:
295306
if finished:
296307
logger.debug('`is_finished_function` reports that we are finished')
297-
elif self._run_result.done() and self._run_result.exception() is not None:
308+
elif run.result.done() and run.result.exception() is not None:
298309
logger.debug('Unhandled exception in `run_task_function`')
299310

300-
if self._worker_tasks:
311+
if run.worker_tasks:
301312
logger.debug('Terminating - waiting for tasks to complete')
302-
await asyncio.wait(self._worker_tasks, return_when=asyncio.ALL_COMPLETED)
313+
await asyncio.wait(run.worker_tasks, return_when=asyncio.ALL_COMPLETED)
303314
logger.debug('Worker tasks finished')
304315
else:
305316
logger.debug('Terminating - no running tasks to wait for')
306317

307-
if not self._run_result.done():
308-
self._run_result.set_result(object())
318+
if not run.result.done():
319+
run.result.set_result(object())
309320

310-
def _reap_worker_task(self, task: asyncio.Task) -> None:
321+
def _reap_worker_task(self, task: asyncio.Task, run: _AutoscaledPoolRun) -> None:
311322
"""A callback for finished worker tasks.
312323
313324
- It interrupts the run in case of an exception,
314325
- keeps track of tasks in progress,
315326
- notifies the orchestrator
316327
"""
317-
self._worker_tasks_updated.set()
318-
self._worker_tasks.remove(task)
328+
run.worker_tasks_updated.set()
329+
run.worker_tasks.remove(task)
319330

320-
if not task.cancelled() and (exception := task.exception()) and not self._run_result.done():
321-
self._run_result.set_exception(exception)
331+
if not task.cancelled() and (exception := task.exception()) and not run.result.done():
332+
run.result.set_exception(exception)
322333

323334
async def _worker_task(self) -> None:
324335
try:

0 commit comments

Comments
 (0)