Skip to content

Commit 97286df

Browse files
committed
add ConcurrencySystem to share one concurrency budget between crawlers
1 parent 24ea410 commit 97286df

27 files changed

Lines changed: 2018 additions & 697 deletions

‎docs/examples/code_examples/resuming_paused_crawl.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616

1717
async def main() -> None:
1818
crawler = BeautifulSoupCrawler(
19-
# Let's slow down the crawler for a demonstration
20-
concurrency_settings=ConcurrencySettings(max_tasks_per_minute=20)
19+
# Let's slow down the crawler for a demonstration. It processes 2 requests
20+
# and then waits for a minute, which leaves time to interrupt it.
21+
concurrency_settings=ConcurrencySettings(max_tasks_per_minute=2)
2122
)
2223

2324
@crawler.router.default_handler

‎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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,65 @@ h2 = await context.query_selector_one('h2', timeout=timedelta(seconds=5))
6060
The storage clients now write their JSON data with camelCase keys, the same as Crawlee JS. This covers the metadata, request and record metadata files of `FileSystemStorageClient`, the request and record metadata JSON stored by `RedisStorageClient`, and the request JSON stored by `SqlStorageClient`. For example, `item_count` in a dataset's `__metadata__.json` becomes `itemCount`. The storage metadata documents of `RedisStorageClient` keep their snake_case keys.
6161

6262
Data written by v1 still loads, so an existing storage directory, Redis instance or database keeps working. Any tooling that reads these files directly needs to handle the camelCase keys.
63+
64+
## Concurrency is managed by `ConcurrencySystem`
65+
66+
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.
67+
68+
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.
69+
70+
`AutoscaledPool` now only runs the task loop. If you create a pool yourself, note that:
71+
72+
- `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.
73+
- `run_task_function` receives the id the task is booked under.
74+
- `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`.
75+
76+
For example:
77+
78+
```python
79+
# v1
80+
from crawlee._autoscaling import AutoscaledPool, Snapshotter, SystemStatus
81+
82+
async with Snapshotter.from_config() as snapshotter:
83+
pool = AutoscaledPool(
84+
system_status=SystemStatus(snapshotter),
85+
concurrency_settings=ConcurrencySettings(max_concurrency=10),
86+
run_task_function=run_task,
87+
is_task_ready_function=is_task_ready,
88+
is_finished_function=is_finished,
89+
)
90+
await pool.run()
91+
92+
# v2
93+
from crawlee.autoscaling import AutoscaledPool, ConcurrencyConsumer, ConcurrencySystem
94+
95+
settings = ConcurrencySettings(max_concurrency=10)
96+
97+
async with ConcurrencySystem(settings) as concurrency_system:
98+
pool = AutoscaledPool(
99+
concurrency_system=concurrency_system,
100+
consumer=ConcurrencyConsumer(id='my-pool'),
101+
run_task_function=run_task, # Now called with the task id.
102+
is_task_ready_function=is_task_ready,
103+
is_finished_function=is_finished,
104+
)
105+
await pool.run()
106+
```
107+
108+
## `max_tasks_per_minute` counts the last minute
109+
110+
`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).
111+
112+
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`.
113+
114+
## Autoscaling logs moved to DEBUG
115+
116+
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.
117+
118+
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:
119+
120+
```text
121+
current concurrency 3, desired 5, overloaded: cpu 58% of the time (limit 40%)
122+
```
123+
124+
To read the concurrency values in code, use `crawler.concurrency_system`.

‎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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from ._autoscaled_pool import AutoscaledPool
2+
from ._concurrency_system import ConcurrencySystem
3+
from ._types import ConcurrencyConsumer, LoadRatioInfo, SystemInfo
4+
5+
__all__ = [
6+
'AutoscaledPool',
7+
'ConcurrencyConsumer',
8+
'ConcurrencySystem',
9+
'LoadRatioInfo',
10+
'SystemInfo',
11+
]

0 commit comments

Comments
 (0)