| from concurrent.futures import Executor |
| from threading import Thread, Lock |
| from collections import namedtuple |
| import weakref |
| import logging |
| |
| try: |
| from time import monotonic |
| except ImportError: # pragma: no cover |
| from monotonic import monotonic |
| |
| from .map import MapFuture |
| from .common import MAX_TIMEOUT |
| from .wrap import CanCustomizeBind |
| from .helpers import executor_loop |
| from .event import get_event, is_shutdown |
| from .logwrap import LogWrapper |
| from .helpers import ShutdownHelper |
| from .metrics import metrics, track_future |
| |
| LOG = LogWrapper(logging.getLogger("TimeoutExecutor")) |
| |
| |
| Job = namedtuple("Job", ["future", "delegate_future", "deadline"]) |
| |
| |
| class TimeoutExecutor(CanCustomizeBind, Executor): |
| """An executor which delegates to another executor while applying |
| a timeout to each returned future. |
| |
| For any futures returned by this executor, if the future hasn't |
| completed approximately within `timeout` seconds of its creation, |
| an attempt will be made to cancel the future. |
| |
| Note that only a single attempt is made to cancel any future, and there |
| is no guarantee that this will succeed. |
| |
| .. versionadded:: 1.7.0 |
| """ |
| |
| def __init__(self, delegate, timeout, logger=None, name="default"): |
| """ |
| Parameters: |
| |
| delegate (~concurrent.futures.Executor): |
| an executor to which callables are submitted |
| |
| timeout (float): |
| timeout (in seconds) after which :meth:`concurrent.futures.Future.cancel()` |
| will be invoked on any generated future which has not completed |
| |
| logger (~logging.Logger): |
| a logger used for messages from this executor |
| |
| name (str): |
| a name for this executor |
| |
| .. versionchanged:: 2.7.0 |
| Introduced ``name``. |
| """ |
| self._log = logger if logger else LOG |
| self._name = name |
| self._delegate = delegate |
| self._timeout = timeout |
| self._shutdown = ShutdownHelper() |
| self._jobs = [] |
| self._jobs_lock = Lock() |
| self._jobs_write = get_event() |
| |
| event = self._jobs_write |
| self_ref = weakref.ref(self, lambda _: event.set()) |
| |
| self._job_thread = Thread( |
| name="TimeoutExecutor-%s" % name, target=self._job_loop, args=(self_ref,) |
| ) |
| self._job_thread.daemon = True |
| self._job_thread.start() |
| |
| metrics.EXEC_TOTAL.labels(type="timeout", executor=self._name).inc() |
| metrics.EXEC_INPROGRESS.labels(type="timeout", executor=self._name).inc() |
| |
| def submit(self, *args, **kwargs): # pylint: disable=arguments-differ |
| return self.submit_timeout(self._timeout, *args, **kwargs) |
| |
| def submit_timeout(self, timeout, fn, *args, **kwargs): |
| """Like :code:`submit(fn, *args, **kwargs)`, but uses the specified |
| timeout rather than this executor's default. |
| |
| .. versionadded:: 1.19.0 |
| """ |
| with self._shutdown.ensure_alive(): |
| delegate_future = self._delegate.submit(fn, *args, **kwargs) |
| future = MapFuture(delegate_future) |
| track_future(future, type="timeout", executor=self._name) |
| |
| future.add_done_callback(self._on_future_done) |
| job = Job(future, delegate_future, monotonic() + timeout) |
| with self._jobs_lock: |
| self._jobs.append(job) |
| self._jobs_write.set() |
| return future |
| |
| def shutdown(self, wait=True, **_kwargs): |
| if self._shutdown(): |
| self._log.debug("shutdown") |
| metrics.EXEC_INPROGRESS.labels(type="timeout", executor=self._name).dec() |
| self._jobs_write.set() |
| self._delegate.shutdown(wait, **_kwargs) |
| if wait: |
| self._job_thread.join(MAX_TIMEOUT) |
| |
| def _partition_jobs(self): |
| pending = [] |
| overdue = [] |
| now = monotonic() |
| for job in self._jobs: |
| if job.future.done(): |
| self._log.debug("Discarding job for completed future: %s", job) |
| elif job.deadline < now: |
| overdue.append(job) |
| else: |
| pending.append(job) |
| return (pending, overdue) |
| |
| def _on_future_done(self, future): |
| self._log.debug("Waking thread for %s", future) |
| self._jobs_write.set() |
| |
| def _do_cancel(self, job): |
| self._log.debug("Attempting cancel: %s", job) |
| cancel_result = job.future.cancel() |
| if cancel_result: |
| metrics.TIMEOUT.labels(executor=self._name).inc() |
| self._log.debug("Cancel of %s resulted in %s", job, cancel_result) |
| |
| @classmethod |
| @executor_loop |
| def _job_loop(cls, executor_ref): |
| while True: |
| event, wait_time = cls._job_loop_iter(executor_ref()) |
| if not event: |
| break |
| event.wait(wait_time) |
| event.clear() |
| |
| @classmethod |
| def _job_loop_iter(cls, executor): |
| if not executor: |
| LOG.debug("Executor was collected") |
| return (None, None) |
| |
| if executor._shutdown.is_shutdown or is_shutdown(): |
| executor._log.debug("Executor was shut down") |
| return (None, None) |
| |
| executor._log.debug("job loop") |
| |
| with executor._jobs_lock: |
| pending, overdue = executor._partition_jobs() |
| executor._jobs = pending |
| |
| executor._log.debug("jobs: %s overdue, %s pending", len(overdue), len(pending)) |
| |
| for job in overdue: |
| executor._do_cancel(job) |
| |
| wait_time = None |
| if pending: |
| earliest = min([job.deadline for job in pending]) |
| wait_time = max(earliest - monotonic(), 0) |
| |
| executor._log.debug("Wait until %s", wait_time) |
| return (executor._jobs_write, wait_time) |