mirror of https://github.com/scrapy/scrapy.git
672 lines
24 KiB
Python
672 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import heapq
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING, Protocol, cast
|
|
|
|
from scrapy.utils.misc import build_from_crawler
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
# typing.Self requires Python 3.11
|
|
from typing_extensions import Self
|
|
|
|
from scrapy import Request
|
|
from scrapy.crawler import Crawler
|
|
from scrapy.throttling import ScopeID, ThrottlingManagerProtocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _path_safe(text: str) -> str:
|
|
"""
|
|
Return a filesystem-safe version of a string ``text``
|
|
|
|
>>> _path_safe('simple.org').startswith('simple.org')
|
|
True
|
|
>>> _path_safe('dash-underscore_.org').startswith('dash-underscore_.org')
|
|
True
|
|
>>> _path_safe('some@symbol?').startswith('some_symbol_')
|
|
True
|
|
"""
|
|
pathable_slot = "".join([c if c.isalnum() or c in "-._" else "_" for c in text])
|
|
# as we replace some letters we can get collision for different slots
|
|
# add we add unique part
|
|
unique_slot = hashlib.md5(text.encode("utf8")).hexdigest() # noqa: S324
|
|
return f"{pathable_slot}-{unique_slot}"
|
|
|
|
|
|
class QueueProtocol(Protocol):
|
|
"""Protocol for downstream queues of ``ScrapyPriorityQueue``."""
|
|
|
|
def push(self, request: Request) -> None: ...
|
|
|
|
def pop(self) -> Request | None: ...
|
|
|
|
def close(self) -> None: ...
|
|
|
|
def __len__(self) -> int: ...
|
|
|
|
|
|
class ScrapyPriorityQueue:
|
|
"""A priority queue implemented using multiple internal queues (typically,
|
|
FIFO queues). It uses one internal queue for each priority value. The
|
|
internal queue must implement the following methods:
|
|
|
|
* push(obj)
|
|
* pop()
|
|
* close()
|
|
* __len__()
|
|
|
|
Optionally, the queue could provide a ``peek`` method, that should return
|
|
the next object to be returned by ``pop``, but without removing it from the
|
|
queue.
|
|
|
|
``__init__`` method of ScrapyPriorityQueue receives a downstream_queue_cls
|
|
argument, which is a class used to instantiate a new (internal) queue when
|
|
a new priority is allocated.
|
|
|
|
Only integer priorities should be used. Lower numbers are higher
|
|
priorities.
|
|
|
|
startprios is a sequence of priorities to start with. If the queue was
|
|
previously closed leaving some priority buckets non-empty, those priorities
|
|
should be passed in startprios.
|
|
|
|
Disk persistence
|
|
================
|
|
|
|
.. warning:: The files that this class generates on disk are an
|
|
implementation detail, and may change without a warning in a future
|
|
version of Scrapy. Do not rely on the following information for
|
|
anything other than debugging purposes.
|
|
|
|
When a component instantiates this class with a non-empty *key* argument,
|
|
*key* is used as a persistence directory.
|
|
|
|
For every request enqueued, this class checks:
|
|
|
|
- Whether the request is a :ref:`start request <start-requests>` or not.
|
|
|
|
- The :data:`~scrapy.Request.priority` of the request.
|
|
|
|
For each combination of the above seen, this class creates an instance of
|
|
*downstream_queue_cls* (or *start_queue_cls* for start requests if it was
|
|
passed) with *key* set to a subdirectory of the persistence directory,
|
|
named as the negated request priority (e.g. ``-1``), with an ``s`` suffix
|
|
in case of a start request (e.g. ``-1s``).
|
|
"""
|
|
|
|
@classmethod
|
|
def from_crawler(
|
|
cls,
|
|
crawler: Crawler,
|
|
downstream_queue_cls: type[QueueProtocol],
|
|
key: str,
|
|
startprios: Iterable[int] = (),
|
|
*,
|
|
start_queue_cls: type[QueueProtocol] | None = None,
|
|
) -> Self:
|
|
return cls(
|
|
crawler,
|
|
downstream_queue_cls,
|
|
key,
|
|
startprios,
|
|
start_queue_cls=start_queue_cls,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
crawler: Crawler,
|
|
downstream_queue_cls: type[QueueProtocol],
|
|
key: str,
|
|
startprios: Iterable[int] = (),
|
|
*,
|
|
start_queue_cls: type[QueueProtocol] | None = None,
|
|
):
|
|
self.crawler: Crawler = crawler
|
|
self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls
|
|
self._start_queue_cls: type[QueueProtocol] | None = start_queue_cls
|
|
self.key: str = key
|
|
self.queues: dict[int, QueueProtocol] = {}
|
|
self._start_queues: dict[int, QueueProtocol] = {}
|
|
self.curprio: int | None = None
|
|
self.init_prios(startprios)
|
|
|
|
def init_prios(self, startprios: Iterable[int]) -> None:
|
|
if not startprios:
|
|
return
|
|
|
|
for priority in startprios:
|
|
q = self.qfactory(priority)
|
|
if q:
|
|
self.queues[priority] = q
|
|
else:
|
|
q.close()
|
|
if self._start_queue_cls:
|
|
q = self._sqfactory(priority)
|
|
if q:
|
|
self._start_queues[priority] = q
|
|
else:
|
|
q.close()
|
|
|
|
self.curprio = min(startprios)
|
|
|
|
def qfactory(self, key: int) -> QueueProtocol:
|
|
return build_from_crawler(
|
|
self.downstream_queue_cls,
|
|
self.crawler,
|
|
self.key + "/" + str(key),
|
|
)
|
|
|
|
def _sqfactory(self, key: int) -> QueueProtocol:
|
|
assert self._start_queue_cls is not None
|
|
return build_from_crawler(
|
|
self._start_queue_cls,
|
|
self.crawler,
|
|
f"{self.key}/{key}s",
|
|
)
|
|
|
|
def priority(self, request: Request) -> int:
|
|
return -request.priority
|
|
|
|
def push(self, request: Request) -> None:
|
|
priority = self.priority(request)
|
|
is_start_request = request.meta.get("is_start_request", False)
|
|
if is_start_request and self._start_queue_cls:
|
|
if priority not in self._start_queues:
|
|
self._start_queues[priority] = self._sqfactory(priority)
|
|
q = self._start_queues[priority]
|
|
else:
|
|
if priority not in self.queues:
|
|
self.queues[priority] = self.qfactory(priority)
|
|
q = self.queues[priority]
|
|
q.push(request) # this may fail (eg. serialization error)
|
|
if self.curprio is None or priority < self.curprio:
|
|
self.curprio = priority
|
|
|
|
def pop(self) -> Request | None:
|
|
while self.curprio is not None:
|
|
try:
|
|
q = self.queues[self.curprio]
|
|
except KeyError:
|
|
pass
|
|
else:
|
|
m = q.pop()
|
|
if not q:
|
|
del self.queues[self.curprio]
|
|
q.close()
|
|
if not self._start_queues:
|
|
self._update_curprio()
|
|
return m
|
|
if self._start_queues:
|
|
try:
|
|
q = self._start_queues[self.curprio]
|
|
except KeyError:
|
|
self._update_curprio()
|
|
else:
|
|
m = q.pop()
|
|
if not q:
|
|
del self._start_queues[self.curprio]
|
|
q.close()
|
|
self._update_curprio()
|
|
return m
|
|
else:
|
|
self._update_curprio()
|
|
return None
|
|
|
|
def _update_curprio(self) -> None:
|
|
prios = {
|
|
p
|
|
for queues in (self.queues, self._start_queues)
|
|
for p, q in queues.items()
|
|
if q
|
|
}
|
|
self.curprio = min(prios) if prios else None
|
|
|
|
def peek(self) -> Request | None:
|
|
"""Returns the next object to be returned by :meth:`pop`,
|
|
but without removing it from the queue.
|
|
|
|
Raises :exc:`NotImplementedError` if the underlying queue class does
|
|
not implement a ``peek`` method, which is optional for queues.
|
|
"""
|
|
if self.curprio is None:
|
|
return None
|
|
# Mirror pop(): at a given priority it drains the regular queue before
|
|
# the start queue, so peek() must report the regular head first for the
|
|
# two to agree on "the next request" (a throttling-aware queue relies on
|
|
# this to check readiness of the exact request pop() will return).
|
|
try:
|
|
queue = self.queues[self.curprio]
|
|
except KeyError:
|
|
queue = self._start_queues[self.curprio]
|
|
# Protocols can't declare optional members
|
|
return cast("Request", queue.peek()) # type: ignore[attr-defined]
|
|
|
|
def close(self) -> list[int]:
|
|
active: set[int] = set()
|
|
for queues in (self.queues, self._start_queues):
|
|
for p, q in queues.items():
|
|
active.add(p)
|
|
q.close()
|
|
return list(active)
|
|
|
|
def __len__(self) -> int:
|
|
return (
|
|
sum(
|
|
len(x)
|
|
for queues in (self.queues, self._start_queues)
|
|
for x in queues.values()
|
|
)
|
|
if self.queues or self._start_queues
|
|
else 0
|
|
)
|
|
|
|
|
|
class DownloaderAwarePriorityQueue:
|
|
"""PriorityQueue which takes Downloader activity into account:
|
|
domains (slots) with the least amount of active downloads are dequeued
|
|
first.
|
|
|
|
Disk persistence
|
|
================
|
|
|
|
.. warning:: The files that this class generates on disk are an
|
|
implementation detail, and may change without a warning in a future
|
|
version of Scrapy. Do not rely on the following information for
|
|
anything other than debugging purposes.
|
|
|
|
When a component instantiates this class with a non-empty *key* argument,
|
|
*key* is used as a persistence directory, and inside that directory this
|
|
class creates a subdirectory per download slot (domain).
|
|
|
|
Those subdirectories are named after the corresponding download slot, with
|
|
path-unsafe characters replaced by underscores and an MD5 hash suffix to
|
|
avoid collisions.
|
|
|
|
For each download slot, this class creates an instance of
|
|
:class:`ScrapyPriorityQueue` with the download slot subdirectory as *key*
|
|
and its own *downstream_queue_cls*.
|
|
"""
|
|
|
|
@classmethod
|
|
def from_crawler(
|
|
cls,
|
|
crawler: Crawler,
|
|
downstream_queue_cls: type[QueueProtocol],
|
|
key: str,
|
|
startprios: dict[str, Iterable[int]] | None = None,
|
|
*,
|
|
start_queue_cls: type[QueueProtocol] | None = None,
|
|
) -> Self:
|
|
return cls(
|
|
crawler,
|
|
downstream_queue_cls,
|
|
key,
|
|
startprios,
|
|
start_queue_cls=start_queue_cls,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
crawler: Crawler,
|
|
downstream_queue_cls: type[QueueProtocol],
|
|
key: str,
|
|
slot_startprios: dict[str, Iterable[int]] | None = None,
|
|
*,
|
|
start_queue_cls: type[QueueProtocol] | None = None,
|
|
):
|
|
if slot_startprios and not isinstance(slot_startprios, dict):
|
|
raise ValueError(
|
|
"DownloaderAwarePriorityQueue accepts "
|
|
"``slot_startprios`` as a dict; "
|
|
f"{slot_startprios.__class__!r} instance "
|
|
"is passed. Most likely, it means the state is "
|
|
"created by an incompatible priority queue. "
|
|
"Only a crawl started with the same priority "
|
|
"queue class can be resumed."
|
|
)
|
|
|
|
assert crawler.throttler is not None
|
|
self._throttler: ThrottlingManagerProtocol = crawler.throttler
|
|
self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls
|
|
self._start_queue_cls: type[QueueProtocol] | None = start_queue_cls
|
|
self.key: str = key
|
|
self.crawler: Crawler = crawler
|
|
|
|
self.pqueues: dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue
|
|
self._last_selected_slot: str | None = None
|
|
if slot_startprios:
|
|
for slot, startprios in slot_startprios.items():
|
|
self.pqueues[slot] = self.pqfactory(slot, startprios)
|
|
|
|
def _next_slot(self, stats: list[tuple[float, str]], *, update_state: bool) -> str:
|
|
last = self._last_selected_slot
|
|
min_load: float | None = None
|
|
best_slot: str | None = None
|
|
best_slot_after_last: str | None = None
|
|
for load, slot in stats:
|
|
if min_load is None or load < min_load:
|
|
min_load = load
|
|
best_slot = slot
|
|
best_slot_after_last = None
|
|
if last is not None and slot > last:
|
|
best_slot_after_last = slot
|
|
elif load == min_load:
|
|
if best_slot is None or slot < best_slot:
|
|
best_slot = slot
|
|
if (
|
|
last is not None
|
|
and slot > last
|
|
and (best_slot_after_last is None or slot < best_slot_after_last)
|
|
):
|
|
best_slot_after_last = slot
|
|
assert best_slot is not None
|
|
slot = best_slot_after_last if best_slot_after_last is not None else best_slot
|
|
if update_state:
|
|
self._last_selected_slot = slot
|
|
return slot
|
|
|
|
def pqfactory(
|
|
self, slot: str, startprios: Iterable[int] = ()
|
|
) -> ScrapyPriorityQueue:
|
|
return ScrapyPriorityQueue(
|
|
self.crawler,
|
|
self.downstream_queue_cls,
|
|
self.key + "/" + _path_safe(slot),
|
|
startprios,
|
|
start_queue_cls=self._start_queue_cls,
|
|
)
|
|
|
|
def _slot_stats(self) -> list[tuple[float, str]]:
|
|
return [(self._throttler.get_scope_load(slot), slot) for slot in self.pqueues]
|
|
|
|
def pop(self) -> Request | None:
|
|
stats = self._slot_stats()
|
|
|
|
if not stats:
|
|
return None
|
|
|
|
slot = self._next_slot(stats, update_state=True)
|
|
queue = self.pqueues[slot]
|
|
request = queue.pop()
|
|
if len(queue) == 0:
|
|
del self.pqueues[slot]
|
|
return request
|
|
|
|
def push(self, request: Request) -> None:
|
|
slot = self._throttler.get_slot_key(request)
|
|
if slot not in self.pqueues:
|
|
self.pqueues[slot] = self.pqfactory(slot)
|
|
queue = self.pqueues[slot]
|
|
queue.push(request)
|
|
|
|
def peek(self) -> Request | None:
|
|
"""Returns the next object to be returned by :meth:`pop`,
|
|
but without removing it from the queue.
|
|
|
|
Raises :exc:`NotImplementedError` if the underlying queue class does
|
|
not implement a ``peek`` method, which is optional for queues.
|
|
"""
|
|
stats = self._slot_stats()
|
|
if not stats:
|
|
return None
|
|
slot = self._next_slot(stats, update_state=False)
|
|
queue = self.pqueues[slot]
|
|
return queue.peek()
|
|
|
|
def close(self) -> dict[str, list[int]]:
|
|
active = {slot: queue.close() for slot, queue in self.pqueues.items()}
|
|
self.pqueues.clear()
|
|
return active
|
|
|
|
def __len__(self) -> int:
|
|
return sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0
|
|
|
|
def __contains__(self, slot: str) -> bool:
|
|
return slot in self.pqueues
|
|
|
|
|
|
def _scope_set_key(scope_set: frozenset[ScopeID]) -> str:
|
|
"""Return a reversible, JSON-safe string key for *scope_set*.
|
|
|
|
Used both as the in-memory dict key encoding for the on-disk state and to
|
|
derive the per-scope-set subdirectory name. The encoding is
|
|
order-independent (the scope ids are sorted)."""
|
|
return json.dumps(sorted(scope_set))
|
|
|
|
|
|
def _scope_set_from_key(key: str) -> frozenset[ScopeID]:
|
|
return frozenset(json.loads(key))
|
|
|
|
|
|
class ThrottlingAwarePriorityQueue:
|
|
"""Priority queue that only ever pops a request that can be sent right now
|
|
based on its :ref:`throttling scope set <throttling-scopes>` and
|
|
per-request :reqmeta:`throttling_delay`.
|
|
|
|
The downstream queue class must support ``peek``.
|
|
|
|
Disk persistence
|
|
================
|
|
|
|
.. warning:: The files that this class generates on disk are an
|
|
implementation detail, and may change without a warning in a future
|
|
version of Scrapy. Do not rely on the following information for
|
|
anything other than debugging purposes.
|
|
|
|
When instantiated with a non-empty *key* argument, *key* is used as a
|
|
persistence directory, and inside it this class creates a subdirectory per
|
|
scope set, named from a path-safe, order-independent encoding of its scope
|
|
ids.
|
|
"""
|
|
|
|
@classmethod
|
|
def from_crawler(
|
|
cls,
|
|
crawler: Crawler,
|
|
downstream_queue_cls: type[QueueProtocol],
|
|
key: str,
|
|
startprios: dict[str, Iterable[int]] | None = None,
|
|
*,
|
|
start_queue_cls: type[QueueProtocol] | None = None,
|
|
) -> Self:
|
|
return cls(
|
|
crawler,
|
|
downstream_queue_cls,
|
|
key,
|
|
startprios,
|
|
start_queue_cls=start_queue_cls,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
crawler: Crawler,
|
|
downstream_queue_cls: type[QueueProtocol],
|
|
key: str,
|
|
slot_startprios: dict[str, Iterable[int]] | None = None,
|
|
*,
|
|
start_queue_cls: type[QueueProtocol] | None = None,
|
|
):
|
|
if slot_startprios and not isinstance(slot_startprios, dict):
|
|
raise ValueError(
|
|
"ThrottlingAwarePriorityQueue accepts ``slot_startprios`` as a "
|
|
f"dict; {slot_startprios.__class__!r} instance is passed. Most "
|
|
"likely, it means the state is created by an incompatible "
|
|
"priority queue. Only a crawl started with the same priority "
|
|
"queue class can be resumed."
|
|
)
|
|
|
|
assert crawler.throttler is not None
|
|
self._throttler: ThrottlingManagerProtocol = crawler.throttler
|
|
self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls
|
|
self._start_queue_cls: type[QueueProtocol] | None = start_queue_cls
|
|
self.key: str = key
|
|
self.crawler: Crawler = crawler
|
|
|
|
# scope set -> priority queue
|
|
self.pqueues: dict[frozenset[ScopeID], ScrapyPriorityQueue] = {}
|
|
# Min-heap of (deadline, seq, scope_set, request) for requests held back
|
|
# by a per-request throttling_delay; seq keeps ordering stable and
|
|
# avoids comparing requests when deadlines tie.
|
|
self._delayed: list[tuple[float, int, frozenset[ScopeID], Request]] = []
|
|
self._delayed_seq: int = 0
|
|
if slot_startprios:
|
|
for set_key, startprios in slot_startprios.items():
|
|
scope_set = _scope_set_from_key(set_key)
|
|
self.pqueues[scope_set] = self.pqfactory(scope_set, startprios)
|
|
|
|
def pqfactory(
|
|
self, scope_set: frozenset[ScopeID], startprios: Iterable[int] = ()
|
|
) -> ScrapyPriorityQueue:
|
|
return ScrapyPriorityQueue(
|
|
self.crawler,
|
|
self.downstream_queue_cls,
|
|
self.key + "/" + _path_safe(_scope_set_key(scope_set)),
|
|
startprios,
|
|
start_queue_cls=self._start_queue_cls,
|
|
)
|
|
|
|
def push(self, request: Request, scope_set: frozenset[ScopeID]) -> None:
|
|
now = time.monotonic()
|
|
self._promote_ready(now)
|
|
delay = self._throttler.get_request_delay(request, now)
|
|
if delay > 0:
|
|
self._delayed_seq += 1
|
|
heapq.heappush(
|
|
self._delayed, (now + delay, self._delayed_seq, scope_set, request)
|
|
)
|
|
return
|
|
self._push_to_queue(request, scope_set)
|
|
|
|
def _push_to_queue(self, request: Request, scope_set: frozenset[ScopeID]) -> None:
|
|
if scope_set not in self.pqueues:
|
|
self.pqueues[scope_set] = self.pqfactory(scope_set)
|
|
self.pqueues[scope_set].push(request)
|
|
|
|
def _promote_ready(self, now: float) -> None:
|
|
"""Move every held-back request whose per-request delay has elapsed into
|
|
its scope-set queue, where it competes normally for its scopes."""
|
|
while self._delayed and self._delayed[0][0] <= now:
|
|
self._release_delayed(heapq.heappop(self._delayed))
|
|
|
|
def _release_delayed(
|
|
self, entry: tuple[float, int, frozenset[ScopeID], Request]
|
|
) -> None:
|
|
_, _, scope_set, request = entry
|
|
# The per-request delay has been honored (or the queue is closing), so
|
|
# mark it consumed: the request must not be delayed again, and on resume
|
|
# it must not re-block its scope set on a stale, no-longer-meaningful
|
|
# deadline.
|
|
request.meta["_throttling_delayed"] = True
|
|
try:
|
|
self._push_to_queue(request, scope_set)
|
|
except ValueError as e:
|
|
# A disk queue serializes on push; held-back requests defer that
|
|
# serialization until here, so a non-serializable one would
|
|
# otherwise raise while flushing on close and take the rest of the
|
|
# disk queue down with it. Drop it with a warning instead, matching
|
|
# how the scheduler handles unserializable requests at enqueue time.
|
|
logger.warning(
|
|
"Unable to serialize request: %(request)s - reason: %(reason)s",
|
|
{"request": request, "reason": e},
|
|
exc_info=True,
|
|
extra={"spider": getattr(self.crawler, "spider", None)},
|
|
)
|
|
if self.crawler.stats is not None:
|
|
self.crawler.stats.inc_value("scheduler/unserializable")
|
|
|
|
def _select(
|
|
self,
|
|
) -> tuple[frozenset[ScopeID], ScrapyPriorityQueue] | None:
|
|
"""Return the sendable ``(scope_set, queue)`` pair to pop from, or
|
|
``None`` if no queue can be popped from right now.
|
|
|
|
Among the sendable queues (those whose scope set can be sent right now),
|
|
the one whose head has the highest request priority is chosen; ties are
|
|
broken by ascending load (the maximum
|
|
:meth:`~scrapy.throttling.ThrottlingManagerProtocol.get_scope_load` over the
|
|
scopes of the queue), i.e. by preferring the least-busy scopes.
|
|
"""
|
|
self._promote_ready(time.monotonic())
|
|
best_sort_key: tuple[int, float] | None = None
|
|
best: tuple[frozenset[ScopeID], ScrapyPriorityQueue] | None = None
|
|
for scope_set, queue in self.pqueues.items():
|
|
head = queue.peek()
|
|
if head is None or not self._throttler.is_ready(head):
|
|
continue
|
|
load = max(
|
|
(self._throttler.get_scope_load(scope_id) for scope_id in scope_set),
|
|
default=0.0,
|
|
)
|
|
sort_key = (queue.priority(head), load)
|
|
if best_sort_key is None or sort_key < best_sort_key:
|
|
best_sort_key = sort_key
|
|
best = (scope_set, queue)
|
|
return best
|
|
|
|
def pop(self) -> Request | None:
|
|
selected = self._select()
|
|
if selected is None:
|
|
return None
|
|
scope_set, queue = selected
|
|
request = queue.pop()
|
|
if request is not None:
|
|
self._throttler.reserve(request)
|
|
if len(queue) == 0:
|
|
del self.pqueues[scope_set]
|
|
return request
|
|
|
|
def peek(self) -> Request | None:
|
|
selected = self._select()
|
|
if selected is None:
|
|
return None
|
|
return selected[1].peek()
|
|
|
|
def get_next_request_delay(self) -> float | None:
|
|
now = time.monotonic()
|
|
self._promote_ready(now)
|
|
delay: float | None = None
|
|
for queue in self.pqueues.values():
|
|
head = queue.peek()
|
|
if head is None:
|
|
continue
|
|
if self._throttler.is_ready(head):
|
|
return 0.0
|
|
head_delay = self._throttler.get_time_until_ready(head)
|
|
if head_delay is None:
|
|
continue
|
|
if delay is None or head_delay < delay:
|
|
delay = head_delay
|
|
# A request held back only by its own throttling_delay is not in any
|
|
# scope-set queue, so factor in when the earliest one is due.
|
|
if self._delayed:
|
|
next_delayed = max(0.0, self._delayed[0][0] - now)
|
|
if delay is None or next_delayed < delay:
|
|
delay = next_delayed
|
|
return delay
|
|
|
|
def close(self) -> dict[str, list[int]]:
|
|
# Flush held-back requests into their scope-set queues so they are
|
|
# persisted (and restored on resume) rather than lost.
|
|
while self._delayed:
|
|
self._release_delayed(heapq.heappop(self._delayed))
|
|
active = {
|
|
_scope_set_key(scope_set): queue.close()
|
|
for scope_set, queue in self.pqueues.items()
|
|
}
|
|
self.pqueues.clear()
|
|
return active
|
|
|
|
def __len__(self) -> int:
|
|
queued = sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0
|
|
return queued + len(self._delayed)
|
|
|
|
def __contains__(self, scope_set: frozenset[ScopeID]) -> bool:
|
|
return scope_set in self.pqueues
|