feat(autoscaling)!: Add `ConcurrencySystem` to share one concurrency … · apify/crawlee-python@51efb37 · GitHub
Skip to content

Commit 51efb37

Browse files
authored
feat(autoscaling)!: Add ConcurrencySystem to share one concurrency budget between crawlers (#2322)
### Description Load monitoring and concurrency scaling move from `AutoscaledPool` into a new `ConcurrencySystem`. Pass one system to several crawlers through the new `concurrency_system` option to cap their combined concurrency. The autoscaling classes are now public in `crawlee.autoscaling`. Autoscaling logs move to DEBUG, and `max_tasks_per_minute` now counts the tasks started over the last 60 seconds. Breaking changes are in the v2 upgrade guide. ### Issues - Closes: #2133 - Closes: #1591 - Closes: #705 *✍️ Drafted by Claude Code*
1 parent 86faf87 commit 51efb37

29 files changed

Lines changed: 2090 additions & 698 deletions

‎docs/examples/code_examples/resuming_paused_crawl.py‎

Lines changed: 3 additions & 2 deletions

‎docs/examples/fill_and_submit_web_form.mdx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ Finally, run your crawler. Your logs should show something like this:
102102
"url": "https://httpbin.org/post"
103103
}
104104
105-
[crawlee._autoscaling.autoscaled_pool] INFO Waiting for remaining tasks to finish
106105
[HttpCrawler] INFO Final request statistics:
107106
┌───────────────────────────────┬─────────┐
108107
│ requests_finished │ 1 │

‎docs/guides/architecture_overview.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,12 @@ The <ApiLink to="class/EventManager">`EventManager`</ApiLink> is responsible for
351351
Crawlee provides several implementations of the event manager:
352352

353353
- <ApiLink to="class/EventManager">`EventManager`</ApiLink> is the base class for event management in Crawlee.
354-
- <ApiLink to="class/LocalEventManager">`LocalEventManager`</ApiLink> extends the base event manager for local environments by automatically emitting `SYSTEM_INFO` events at regular intervals. This provides real-time system metrics including CPU usage and memory consumption, which are essential for internal components like the <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink>.
354+
- <ApiLink to="class/LocalEventManager">`LocalEventManager`</ApiLink> extends the base event manager for local environments by automatically emitting `SYSTEM_INFO` events at regular intervals. This provides real-time system metrics including CPU usage and memory consumption, which are essential for internal components like the <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/ConcurrencySystem">`ConcurrencySystem`</ApiLink>.
355355
- [`ApifyEventManager`](https://docs.apify.com/sdk/python/reference/class/ApifyEventManager) - Manages events on the [Apify platform](https://apify.com/) (cloud-based). It is implemented in the [Apify SDK](https://docs.apify.com/sdk/python/).
356356

357357
:::info
358358

359-
You can learn more about <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink> and their configuration in the [Scaling crawlers guide](./scaling-crawlers).
359+
You can learn more about <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/ConcurrencySystem">`ConcurrencySystem`</ApiLink> and their configuration in the [Scaling crawlers guide](./scaling-crawlers).
360360

361361
:::
362362

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import asyncio
2+
3+
from crawlee import ConcurrencySettings
4+
from crawlee.autoscaling import ConcurrencySystem
5+
from crawlee.crawlers import (
6+
BeautifulSoupCrawler,
7+
BeautifulSoupCrawlingContext,
8+
ParselCrawler,
9+
ParselCrawlingContext,
10+
)
11+
from crawlee.storages import RequestQueue
12+
13+
14+
async def main() -> None:
15+
concurrency_settings = ConcurrencySettings(max_concurrency=10)
16+
17+
# Each crawler needs a request queue of its own, otherwise they would both
18+
# consume the default one and process each other's requests.
19+
first_queue = await RequestQueue.open(alias='first-crawler')
20+
second_queue = await RequestQueue.open(alias='second-crawler')
21+
22+
# One budget for both crawlers, instead of one each.
23+
async with ConcurrencySystem(concurrency_settings) as concurrency_system:
24+
first_crawler = BeautifulSoupCrawler(
25+
concurrency_system=concurrency_system,
26+
request_manager=first_queue,
27+
)
28+
second_crawler = ParselCrawler(
29+
concurrency_system=concurrency_system,
30+
request_manager=second_queue,
31+
)
32+
33+
@first_crawler.router.default_handler
34+
async def first_handler(context: BeautifulSoupCrawlingContext) -> None:
35+
context.log.info(f'Processing {context.request.url}...')
36+
37+
@second_crawler.router.default_handler
38+
async def second_handler(context: ParselCrawlingContext) -> None:
39+
context.log.info(f'Processing {context.request.url}...')
40+
41+
await asyncio.gather(
42+
first_crawler.run(['https://crawlee.dev/']),
43+
second_crawler.run(['https://crawlee.dev/python/']),
44+
)
45+
46+
47+
if __name__ == '__main__':
48+
asyncio.run(main())

‎docs/guides/scaling_crawlers.mdx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import RunnableCodeBlock from '@site/src/components/RunnableCodeBlock';
99

1010
import MaxTasksPerMinuteExample from '!!raw-loader!roa-loader!./code_examples/scaling_crawlers/max_tasks_per_minute_example.py';
1111
import MinAndMaxConcurrencyExample from '!!raw-loader!roa-loader!./code_examples/scaling_crawlers/min_and_max_concurrency_example.py';
12+
import SharedConcurrencySystemExample from '!!raw-loader!roa-loader!./code_examples/scaling_crawlers/shared_concurrency_system_example.py';
1213

1314
As we build our crawler, we may want to control how many tasks it performs at any given time. In other words, how many requests it makes to the web we are trying to scrape. Crawlee offers several options to fine-tune the number of parallel tasks, limit the number of requests per minute, and optimize scaling based on available system resources.
1415

@@ -20,7 +21,7 @@ All of these options are available across all crawlers provided by Crawlee. In t
2021

2122
## Max tasks per minute
2223

23-
The `max_tasks_per_minute` setting in <ApiLink to="class/ConcurrencySettings">`ConcurrencySettings`</ApiLink> controls how many total tasks the crawler can process per minute. It ensures that tasks are spread evenly throughout the minute, preventing a sudden burst at the `max_concurrency` limit followed by idle time. By default, this is set to `Infinity`, meaning the crawler can run at full speed, limited only by `max_concurrency`. Use this option if you want to throttle your crawler to avoid overwhelming the target website with continuous requests.
24+
The `max_tasks_per_minute` setting in <ApiLink to="class/ConcurrencySettings">`ConcurrencySettings`</ApiLink> controls how many total tasks the crawler can process per minute. The tasks started over the last 60 seconds are counted, so the crawler may spend the whole limit early in the minute, as fast as its concurrency allows. It then waits until the oldest of those starts ages out of the window. By default, this is set to `Infinity`, meaning the crawler can run at full speed, limited only by `max_concurrency`. Use this option if you want to throttle your crawler to avoid overwhelming the target website with continuous requests.
2425

2526
<RunnableCodeBlock className="language-python" language="python">
2627
{MaxTasksPerMinuteExample}
@@ -44,9 +45,15 @@ The `desired_concurrency` option in the <ApiLink to="class/ConcurrencySettings">
4445
{MinAndMaxConcurrencyExample}
4546
</RunnableCodeBlock>
4647

47-
## Autoscaled pool
48+
## Concurrency system
4849

49-
The <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink> manages a pool of asynchronous, resource-intensive tasks that run in parallel. It keeps `min_concurrency` tasks running even while the system is overloaded, and starts additional tasks only when there is enough free CPU and memory. To monitor system resources, it leverages the <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/SystemStatus">`SystemStatus`</ApiLink> classes. If any task raises an exception, the error is propagated, and the pool is stopped. Every crawler uses an <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink> under the hood.
50+
Every crawler runs its requests through an <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink>, and the pool asks a <ApiLink to="class/ConcurrencySystem">`ConcurrencySystem`</ApiLink> whether there is room for one more request. The system is what keeps `min_concurrency` requests running even while the machine is overloaded, starts further ones only when there is enough free CPU and memory, and scales the budget up and down as the load changes. To monitor system resources, it leverages the <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/SystemStatus">`SystemStatus`</ApiLink> classes. If any task raises an exception, the error is propagated and the pool is stopped.
51+
52+
The crawler builds its own system from the `ConcurrencySettings` described above. Build one yourself and pass it as `concurrency_system` to go beyond those settings, or to share a single budget between several crawlers that would otherwise each scale against the whole machine and oversubscribe it. The budget goes to whoever asks for it first, so a crawler that joins a saturated system late may have to wait. Browser crawlers default to a lower `desired_concurrency` than the rest, and a supplied system replaces that default too, so set it in the `ConcurrencySettings` you build the system from. Holding the instance also lets you retune `min_concurrency` and `max_concurrency` while the crawl runs. The lifecycle of such a system belongs to you, so keep it open until every crawler using it has finished.
53+
54+
<RunnableCodeBlock className="language-python" language="python">
55+
{SharedConcurrencySystemExample}
56+
</RunnableCodeBlock>
5057

5158
## Running under a resource limit
5259

‎docs/guides/scrapy_migration.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ The default (unnamed) <ApiLink to="class/Dataset">`Dataset`</ApiLink> stays on d
195195

196196
## Concurrency and throttling
197197

198-
Both Scrapy and Crawlee have settings to influence the scraping throughput, but they differ in their approach. Crawlee reads <ApiLink to="class/ConcurrencySettings">`ConcurrencySettings`</ApiLink> and scales concurrency automatically based on system resources, as described in the [Scaling crawlers guide](./scaling-crawlers). Use <ApiLink to="class/ConcurrencySettings#__init__">`max_tasks_per_minute`</ApiLink> to cap the request rate. It's close to `DOWNLOAD_DELAY`, but it limits the whole pool rather than adding a fixed delay per domain.
198+
Both Scrapy and Crawlee have settings to influence the scraping throughput, but they differ in their approach. Crawlee reads <ApiLink to="class/ConcurrencySettings">`ConcurrencySettings`</ApiLink> and scales concurrency automatically based on system resources, as described in the [Scaling crawlers guide](./scaling-crawlers). Use <ApiLink to="class/ConcurrencySettings#__init__">`max_tasks_per_minute`</ApiLink> to cap the request rate. It's close to `DOWNLOAD_DELAY`, but it limits the whole pool rather than adding a fixed delay per domain. Requests aren't spaced out either. They start as fast as the concurrency allows until the cap for the last 60 seconds is reached, and then wait for that window to move on.
199199

200200
<Tabs groupId="scrapy-migration-concurrency">
201201
<TabItem value="scrapy" label="Scrapy">

‎docs/upgrading/upgrading_to_v2.md‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,67 @@ The `id` argument of `BasicCrawler` and its subclasses is now a `str`, so pass a
103103
The statistics a crawler creates by default now use the crawler's `id`. Crawlers that share an `id` therefore share both the state returned by `use_state` and the persisted statistics. In v1, they shared only the state.
104104

105105
A crawler without an `id` gets its creation index, such as `'0'`, and crawlers with an explicit `id` now count towards that index too. If you resume a v1 run with `purge_on_start=False` and mix both kinds of crawlers, a generated `id` can differ from the one v1 assigned, and the crawler won't find the state that `use_state` stored under the old one.
106+
107+
## Concurrency is managed by `ConcurrencySystem`
108+
109+
Load monitoring and concurrency scaling moved from `AutoscaledPool` to the new `ConcurrencySystem`. A crawler builds a default system from its `concurrency_settings` for each run, so a crawler configured through `concurrency_settings` works as before. To cap the combined concurrency of several crawlers, pass one shared `ConcurrencySystem` to all of them through the new `concurrency_system` option.
110+
111+
The autoscaling classes now live in the public `crawlee.autoscaling` package, which replaces the private `crawlee._autoscaling`. Import `AutoscaledPool`, `ConcurrencySystem`, `ConcurrencyConsumer`, `SystemInfo` and `LoadRatioInfo` from there. `Snapshotter` and `SystemStatus` stay internal.
112+
113+
`AutoscaledPool` now only runs the task loop. If you create a pool yourself, note that:
114+
115+
- `system_status` and `concurrency_settings` are replaced by `concurrency_system`, which has to be active when the pool runs, and `consumer`, which identifies the pool to the system.
116+
- `run_task_function` receives the id the task is booked under.
117+
- `current_concurrency` comes from the concurrency system, so a shared system may count the tasks of other pools too. The pool's own tasks are counted by the new `own_concurrency`.
118+
119+
For example:
120+
121+
```python
122+
# v1
123+
from crawlee._autoscaling import AutoscaledPool, Snapshotter, SystemStatus
124+
125+
async with Snapshotter.from_config() as snapshotter:
126+
pool = AutoscaledPool(
127+
system_status=SystemStatus(snapshotter),
128+
concurrency_settings=ConcurrencySettings(max_concurrency=10),
129+
run_task_function=run_task,
130+
is_task_ready_function=is_task_ready,
131+
is_finished_function=is_finished,
132+
)
133+
await pool.run()
134+
135+
# v2
136+
from crawlee.autoscaling import AutoscaledPool, ConcurrencyConsumer, ConcurrencySystem
137+
138+
settings = ConcurrencySettings(max_concurrency=10)
139+
140+
async with ConcurrencySystem(settings) as concurrency_system:
141+
pool = AutoscaledPool(
142+
concurrency_system=concurrency_system,
143+
consumer=ConcurrencyConsumer(id='my-pool'),
144+
run_task_function=run_task, # Now called with the task id.
145+
is_task_ready_function=is_task_ready,
146+
is_finished_function=is_finished,
147+
)
148+
await pool.run()
149+
```
150+
151+
## `max_tasks_per_minute` counts the last minute
152+
153+
`ConcurrencySettings.max_tasks_per_minute` now caps the tasks started over the last 60 seconds, the same as Crawlee JS. In v1, the pool spaced task starts evenly, `60 / max_tasks_per_minute` seconds apart. In v2, tasks start as fast as the concurrency allows until the cap is reached. The next ones wait until the oldest starts age out of the window. The rate over a minute stays the same, but tasks start in bursts. For details, see [Max tasks per minute](../guides/scaling-crawlers#max-tasks-per-minute).
154+
155+
The value has to be a whole number of at least 1, or infinity, which is the default. A fractional value, such as `0.5` or `7.5`, now raises `ValueError`.
156+
157+
## Autoscaling logs changed
158+
159+
The periodic log of the autoscaling state and the `Waiting for remaining tasks to finish` message are now logged at DEBUG instead of INFO. The crawler's status message at INFO still reports the desired concurrency.
160+
161+
The state log also has a new format. Instead of raw ratios for every resource, it names only the overloaded ones, with the share of time each spent over its threshold and its limit:
162+
163+
```text
164+
current concurrency 3, desired 5, overloaded: cpu 58% of the time (limit 40%)
165+
```
166+
167+
To read the concurrency values in code, use `crawler.concurrency_system`.
168+
169+
The autoscaling loggers moved together with the package. `crawlee._autoscaling.autoscaled_pool`, `crawlee._autoscaling.snapshotter` and `crawlee._autoscaling.system_status` are now `crawlee.autoscaling._autoscaled_pool`, `crawlee.autoscaling._snapshotter` and `crawlee.autoscaling._system_status`. The state log, along with the messages about scaling and free capacity, now comes from the new `crawlee.autoscaling._concurrency_system` logger. To configure all of them at once, use the `crawlee.autoscaling` logger.

‎src/crawlee/_autoscaling/__init__.py‎

Lines changed: 0 additions & 5 deletions
This file was deleted.

‎src/crawlee/_types.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def __len__(self) -> int:
100100

101101
@docs_group('Configuration')
102102
class ConcurrencySettings:
103-
"""Concurrency settings for AutoscaledPool."""
103+
"""Concurrency settings for a `ConcurrencySystem`."""
104104

105105
def __init__(
106106
self,
@@ -116,8 +116,8 @@ def __init__(
116116
min_concurrency: The minimum number of tasks running in parallel. If you set this value too high
117117
with respect to the available system memory and CPU, your code might run extremely slow or crash.
118118
max_concurrency: The maximum number of tasks running in parallel.
119-
max_tasks_per_minute: The maximum number of tasks per minute the pool can run. By default, this is set
120-
to infinity, but you can pass any positive, non-zero number.
119+
max_tasks_per_minute: The maximum number of tasks per minute the crawler can run. By default, this is set
120+
to infinity, but you can pass any whole number of at least 1.
121121
desired_concurrency: The desired number of tasks that should be running parallel on the start of the pool,
122122
if there is a large enough supply of them.
123123
"""
@@ -133,8 +133,10 @@ def __init__(
133133
if desired_concurrency > max_concurrency:
134134
raise ValueError('desired_concurrency cannot be greater than max_concurrency')
135135

136-
if max_tasks_per_minute <= 0:
137-
raise ValueError('max_tasks_per_minute must be positive')
136+
if max_tasks_per_minute != float('inf') and (
137+
max_tasks_per_minute < 1 or not float(max_tasks_per_minute).is_integer()
138+
):
139+
raise ValueError('max_tasks_per_minute must be a whole number of at least 1, or infinity')
138140

139141
self.min_concurrency = min_concurrency
140142
self.max_concurrency = max_concurrency
Lines changed: 11 additions & 0 deletions

0 commit comments

Comments
 (0)