-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathscheduler.py
More file actions
203 lines (159 loc) · 8.29 KB
/
Copy pathscheduler.py
File metadata and controls
203 lines (159 loc) · 8.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
from __future__ import annotations
from datetime import timedelta
from logging import getLogger
from typing import TYPE_CHECKING
from scrapy import Spider
from scrapy.core.scheduler import BaseScheduler
from scrapy.utils.reactor import is_asyncio_reactor_installed
from ._async_thread import AsyncThread
from .requests import to_apify_request, to_scrapy_request
from apify import Configuration
from apify.storage_clients import ApifyStorageClient
from apify.storages import RequestQueue
if TYPE_CHECKING:
from scrapy.crawler import Crawler
from scrapy.http.request import Request
from twisted.internet.defer import Deferred
logger = getLogger(__name__)
class ApifyScheduler(BaseScheduler):
"""A Scrapy scheduler that uses the Apify `RequestQueue` to manage requests.
This scheduler requires the asyncio Twisted reactor to be installed.
"""
def __init__(self, async_thread_timeout: timedelta = timedelta(seconds=60)) -> None:
if not is_asyncio_reactor_installed():
raise ValueError(
f'{ApifyScheduler.__qualname__} requires the asyncio Twisted reactor. '
'Make sure you have it configured in the TWISTED_REACTOR setting. See the asyncio '
'documentation of Scrapy for more information.',
)
self._rq: RequestQueue | None = None
self.spider: Spider | None = None
# A thread with the asyncio event loop to run coroutines on.
self._async_thread = AsyncThread(default_timeout=async_thread_timeout)
@classmethod
def from_crawler(cls, crawler: Crawler) -> ApifyScheduler:
"""Create the scheduler, reading the async-thread timeout from the Scrapy settings.
The `APIFY_ASYNC_THREAD_TIMEOUT_SECS` setting (in seconds) caps how long each coroutine run on the
background event loop may take before timing out; it defaults to 60 seconds.
"""
timeout_secs = crawler.settings.getint('APIFY_ASYNC_THREAD_TIMEOUT_SECS', 60)
return cls(async_thread_timeout=timedelta(seconds=timeout_secs))
def open(self, spider: Spider) -> Deferred[None] | None:
"""Open the scheduler.
Args:
spider: The spider that the scheduler is associated with.
"""
self.spider = spider
async def open_rq() -> RequestQueue:
configuration = Configuration.get_global_configuration()
if configuration.is_at_home:
storage_client = ApifyStorageClient()
return await RequestQueue.open(
configuration=configuration,
storage_client=storage_client,
)
return await RequestQueue.open()
try:
self._rq = self._async_thread.run_coro(open_rq())
except Exception:
logger.exception('Failed to open the request queue.')
# Close the freshly started async thread so a failed open does not leak its event-loop thread.
# Guard the close so a secondary failure here cannot mask the original error.
try:
self._async_thread.close()
except Exception:
logger.exception('Failed to close the async thread after a failed scheduler open.')
raise
return None
def close(self, reason: str) -> None:
"""Close the scheduler.
Shut down the event loop and its thread gracefully.
Args:
reason: The reason for closing the spider.
"""
logger.debug(f'Closing {self.__class__.__name__} due to {reason}...')
try:
self._async_thread.close()
except KeyboardInterrupt:
logger.warning('Shutdown interrupted by KeyboardInterrupt!')
except Exception:
logger.exception('Exception occurred while shutting down.')
finally:
logger.debug(f'{self.__class__.__name__} closed successfully.')
def has_pending_requests(self) -> bool:
"""Check if the scheduler has any pending requests.
Returns:
True if the scheduler has any pending requests, False otherwise.
"""
if not isinstance(self._rq, RequestQueue):
raise TypeError('self._rq must be an instance of the RequestQueue class')
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
try:
is_finished = self._async_thread.run_coro(self._rq.is_finished())
except Exception:
logger.exception('Failed to check whether the request queue is finished.')
raise
return not is_finished
def enqueue_request(self, request: Request) -> bool:
"""Add a request to the scheduler.
This could be called from either from a spider or a downloader middleware (e.g. redirect, retry, ...).
Args:
request: The request to add to the scheduler.
Returns:
True if the request was successfully enqueued, False otherwise.
"""
if not isinstance(self.spider, Spider):
raise TypeError('self.spider must be an instance of the Spider class')
apify_request = to_apify_request(request, spider=self.spider)
if apify_request is None:
logger.warning(f'Request {request} could not be converted to Apify request; skipping it.')
return False
if not isinstance(self._rq, RequestQueue):
raise TypeError('self._rq must be an instance of the RequestQueue class')
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
try:
result = self._async_thread.run_coro(self._rq.add_request(apify_request))
except Exception:
logger.exception('Failed to enqueue the request to the request queue.')
raise
return not bool(result.was_already_present)
def next_request(self) -> Request | None:
"""Fetch the next request from the scheduler.
Returns:
The next request, or None if there are no more requests.
"""
if not isinstance(self._rq, RequestQueue):
raise TypeError('self._rq must be an instance of the RequestQueue class')
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
try:
apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
except Exception:
logger.exception('Failed to fetch the next request from the request queue.')
raise
if apify_request is None:
return None
if not isinstance(self.spider, Spider):
raise TypeError('self.spider must be an instance of the Spider class')
# Reconstruct the Scrapy request before consuming the queue entry. A malformed entry must not crash
# the whole run, so on failure it is logged and skipped (None) rather than propagating.
try:
scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
except Exception as exc:
logger.warning(f'Failed to convert Apify request {apify_request} to a Scrapy request; skipping it: {exc}')
scrapy_request = None
# Mark the request as handled. This runs even when reconstruction failed above: an unrecoverable entry
# (a corrupt or legacy payload) must still be consumed, otherwise the queue would keep handing it back
# forever. Retrying genuine failures is the RetryMiddleware's job.
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
try:
self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
except Exception:
logger.exception('Failed to mark the request as handled in the request queue.')
raise
if scrapy_request is None:
return None
return scrapy_request