• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

agronholm / apscheduler / 19791780910

30 Nov 2025 12:05AM UTC coverage: 92.793%. First build
19791780910

push

github

agronholm
Applied more Ruff rules and the fixes that they required

9 of 11 new or added lines in 6 files covered. (81.82%)

3489 of 3760 relevant lines covered (92.79%)

7.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.77
/src/apscheduler/_schedulers/async_.py
1
from __future__ import annotations
9✔
2

3
import os
9✔
4
import platform
9✔
5
import random
9✔
6
import sys
9✔
7
from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence
9✔
8
from contextlib import AsyncExitStack
9✔
9
from datetime import datetime, timedelta, timezone
9✔
10
from functools import partial
9✔
11
from inspect import isbuiltin, isclass, ismethod, ismodule
9✔
12
from logging import Logger, getLogger
9✔
13
from types import TracebackType
9✔
14
from typing import Any, Literal, TypeAlias, TypeVar, cast, overload
9✔
15
from uuid import UUID, uuid4
9✔
16

17
import anyio
9✔
18
import attrs
9✔
19
from anyio import (
9✔
20
    TASK_STATUS_IGNORED,
21
    CancelScope,
22
    create_task_group,
23
    get_cancelled_exc_class,
24
    move_on_after,
25
    sleep,
26
)
27
from anyio.abc import TaskGroup, TaskStatus
9✔
28
from attr.validators import instance_of, optional
9✔
29

30
from .. import JobAdded, SerializationError, TaskLookupError
9✔
31
from .._context import current_async_scheduler, current_job
9✔
32
from .._converters import as_enum, as_timedelta
9✔
33
from .._decorators import TaskParameters, get_task_params
9✔
34
from .._enums import CoalescePolicy, ConflictPolicy, JobOutcome, RunState, SchedulerRole
9✔
35
from .._events import (
9✔
36
    Event,
37
    JobReleased,
38
    ScheduleAdded,
39
    SchedulerStarted,
40
    SchedulerStopped,
41
    ScheduleUpdated,
42
    T_Event,
43
)
44
from .._exceptions import (
9✔
45
    CallableLookupError,
46
    DeserializationError,
47
    JobCancelled,
48
    JobDeadlineMissed,
49
    JobLookupError,
50
    ScheduleLookupError,
51
)
52
from .._marshalling import callable_from_ref, callable_to_ref
9✔
53
from .._structures import (
9✔
54
    Job,
55
    JobResult,
56
    MetadataType,
57
    Schedule,
58
    ScheduleResult,
59
    Task,
60
    TaskDefaults,
61
)
62
from .._utils import UnsetValue, create_repr, merge_metadata, unset
9✔
63
from .._validators import non_negative_number
9✔
64
from ..abc import DataStore, EventBroker, JobExecutor, Subscription, Trigger
9✔
65
from ..datastores.memory import MemoryDataStore
9✔
66
from ..eventbrokers.local import LocalEventBroker
9✔
67
from ..executors.async_ import AsyncJobExecutor
9✔
68
from ..executors.subprocess import ProcessPoolJobExecutor
9✔
69
from ..executors.thread import ThreadPoolJobExecutor
9✔
70

71
if sys.version_info >= (3, 11):
9✔
72
    from typing import Self
6✔
73
else:
74
    from typing_extensions import Self
3✔
75

76
_microsecond_delta = timedelta(microseconds=1)
9✔
77
_zero_timedelta = timedelta()
9✔
78

79
TaskType: TypeAlias = "Task | str | Callable[..., Any]"
9✔
80
T = TypeVar("T")
9✔
81

82

83
@attrs.define(eq=False, repr=False)
9✔
84
class AsyncScheduler:
9✔
85
    """
86
    An asynchronous (AnyIO based) scheduler implementation.
87

88
    Requires either :mod:`asyncio` or Trio_ to work.
89

90
    .. note:: If running on Trio, ensure that the data store and event broker are
91
        compatible with Trio.
92

93
    .. _AnyIO: https://pypi.org/project/anyio/
94
    .. _Trio: https://pypi.org/project/trio/
95

96
    :param data_store: the data store for tasks, schedules and jobs
97
    :param event_broker: the event broker to use for publishing an subscribing events
98
    :param identity: the unique identifier of the scheduler
99
    :param role: specifies what the scheduler should be doing when running (scheduling
100
        only, job running only, or both)
101
    :param max_concurrent_jobs: Maximum number of jobs the scheduler will run at once
102
    :param job_executors: a mutable mapping of executor names to executor instances
103
    :param task_defaults: default settings for newly configured tasks
104
    :param cleanup_interval: interval (as seconds or timedelta) between automatic
105
        calls to :meth:`cleanup` – ``None`` to disable automatic clean-up
106
    :param lease_duration: maximum amount of time (as seconds or timedelta) that
107
        the scheduler can keep a lock on a schedule or task
108
    :param logger: the logger instance used to log events from the scheduler, data store
109
        and event broker
110
    """
111

112
    data_store: DataStore = attrs.field(
9✔
113
        validator=instance_of(DataStore), factory=MemoryDataStore
114
    )
115
    event_broker: EventBroker = attrs.field(
9✔
116
        validator=instance_of(EventBroker), factory=LocalEventBroker
117
    )
118
    identity: str = attrs.field(kw_only=True, validator=instance_of(str), default="")
9✔
119
    role: SchedulerRole = attrs.field(
9✔
120
        kw_only=True, converter=as_enum(SchedulerRole), default=SchedulerRole.both
121
    )
122
    task_defaults: TaskDefaults = attrs.field(kw_only=True, factory=TaskDefaults)
9✔
123
    max_concurrent_jobs: int = attrs.field(
9✔
124
        kw_only=True, validator=non_negative_number, default=100
125
    )
126
    job_executors: MutableMapping[str, JobExecutor] = attrs.field(
9✔
127
        kw_only=True, validator=instance_of(MutableMapping), factory=dict
128
    )
129
    cleanup_interval: timedelta | None = attrs.field(
9✔
130
        kw_only=True,
131
        converter=as_timedelta,
132
        validator=optional(instance_of(timedelta)),
133
        default=timedelta(minutes=15),
134
    )
135
    lease_duration: timedelta = attrs.field(converter=as_timedelta, default=30)
9✔
136
    logger: Logger = attrs.field(kw_only=True, default=getLogger(__name__))
9✔
137

138
    _state: RunState = attrs.field(init=False, default=RunState.stopped)
9✔
139
    _services_task_group: TaskGroup | None = attrs.field(init=False, default=None)
9✔
140
    _exit_stack: AsyncExitStack = attrs.field(init=False)
9✔
141
    _services_initialized: bool = attrs.field(init=False, default=False)
9✔
142
    _scheduler_cancel_scope: CancelScope | None = attrs.field(init=False, default=None)
9✔
143
    _running_jobs: set[Job] = attrs.field(init=False, factory=set)
9✔
144
    _task_callables: dict[str, Callable] = attrs.field(init=False, factory=dict)
9✔
145

146
    def __attrs_post_init__(self) -> None:
9✔
147
        if not self.identity:
9✔
148
            self.identity = f"{platform.node()}-{os.getpid()}-{id(self)}"
9✔
149

150
        if not self.job_executors:
9✔
151
            self.job_executors = {
9✔
152
                "async": AsyncJobExecutor(),
153
                "threadpool": ThreadPoolJobExecutor(),
154
                "processpool": ProcessPoolJobExecutor(),
155
            }
156

157
        if self.task_defaults.job_executor is unset:
9✔
158
            self.task_defaults.job_executor = next(iter(self.job_executors))
9✔
159
        elif self.task_defaults.job_executor not in self.job_executors:
9✔
160
            valid_executors = ", ".join(self.job_executors)
4✔
161
            raise ValueError(
6✔
162
                f"the default job executor must be one of the given job executors "
163
                f"({valid_executors})"
164
            )
165

166
        if self.task_defaults.max_running_jobs is unset:
9✔
167
            self.task_defaults.max_running_jobs = 1
6✔
168

169
        if self.task_defaults.misfire_grace_time is unset:
9✔
170
            self.task_defaults.misfire_grace_time = None
×
171

172
    async def __aenter__(self) -> Self:
9✔
173
        async with AsyncExitStack() as exit_stack:
9✔
174
            await self._ensure_services_initialized(exit_stack)
9✔
175
            self._services_task_group = await exit_stack.enter_async_context(
9✔
176
                create_task_group()
177
            )
178
            exit_stack.callback(setattr, self, "_services_task_group", None)
9✔
179
            exit_stack.push_async_callback(self.stop)
9✔
180
            self._exit_stack = exit_stack.pop_all()
9✔
181

182
        return self
9✔
183

184
    async def __aexit__(
9✔
185
        self,
186
        exc_type: type[BaseException] | None,
187
        exc_val: BaseException | None,
188
        exc_tb: TracebackType | None,
189
    ) -> None:
190
        await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
9✔
191

192
    def __repr__(self) -> str:
9✔
193
        return create_repr(self, "identity", "role", "data_store", "event_broker")
9✔
194

195
    async def _ensure_services_initialized(self, exit_stack: AsyncExitStack) -> None:
9✔
196
        """
197
        Initialize the data store and event broker if this hasn't already been done.
198

199
        """
200
        if not self._services_initialized:
9✔
201
            self._services_initialized = True
9✔
202
            exit_stack.callback(setattr, self, "_services_initialized", False)
9✔
203

204
            await self.event_broker.start(exit_stack, self.logger)
9✔
205
            await self.data_store.start(exit_stack, self.event_broker, self.logger)
9✔
206

207
    def _check_initialized(self) -> None:
9✔
208
        """Raise RuntimeError if the services have not been initialized yet."""
209
        if not self._services_initialized:
9✔
210
            raise RuntimeError(
9✔
211
                "The scheduler has not been initialized yet. Use the scheduler as an "
212
                "async context manager (async with ...) in order to call methods other "
213
                "than run_until_stopped()."
214
            )
215

216
    async def _cleanup_loop(self) -> None:
9✔
217
        delay = self.cleanup_interval.total_seconds()
9✔
218
        assert delay > 0
9✔
219
        while self._state in (RunState.starting, RunState.started):
9✔
220
            await self.cleanup()
9✔
221
            await sleep(delay)
9✔
222

223
    @property
9✔
224
    def state(self) -> RunState:
9✔
225
        """The current running state of the scheduler."""
226
        return self._state
9✔
227

228
    async def cleanup(self) -> None:
9✔
229
        """Clean up expired job results and finished schedules."""
230
        await self.data_store.cleanup()
9✔
231
        self.logger.info("Cleaned up expired job results and finished schedules")
9✔
232

233
    @overload
234
    def subscribe(
235
        self,
236
        callback: Callable[[T_Event], Any],
237
        event_types: type[T_Event],
238
        *,
239
        one_shot: bool = ...,
240
        is_async: bool = ...,
241
    ) -> Subscription: ...
242

243
    @overload
244
    def subscribe(
245
        self,
246
        callback: Callable[[Event], Any],
247
        event_types: Iterable[type[Event]] | None = None,
248
        *,
249
        one_shot: bool = False,
250
        is_async: bool = True,
251
    ) -> Subscription: ...
252

253
    def subscribe(
9✔
254
        self,
255
        callback: Callable[[T_Event], Any],
256
        event_types: type[T_Event] | Iterable[type[T_Event]] | None = None,
257
        *,
258
        one_shot: bool = False,
259
        is_async: bool = True,
260
    ) -> Subscription:
261
        """
262
        Subscribe to events.
263

264
        To unsubscribe, call the :meth:`~abc.Subscription.unsubscribe` method on the
265
        returned object.
266

267
        :param callback: callable to be called with the event object when an event is
268
            published
269
        :param event_types: an event class or an iterable event classes to subscribe to
270
        :param one_shot: if ``True``, automatically unsubscribe after the first matching
271
            event
272
        :param is_async: ``True`` if the (synchronous) callback should be called on the
273
            event loop thread, ``False`` if it should be called in a worker thread.
274
            If ``callback`` is a coroutine function, this flag is ignored.
275

276
        """
277
        self._check_initialized()
9✔
278
        if isclass(event_types):
9✔
279
            event_types = {event_types}
9✔
280

281
        return self.event_broker.subscribe(
9✔
282
            callback, event_types, is_async=is_async, one_shot=one_shot
283
        )
284

285
    @overload
286
    async def get_next_event(self, event_types: type[T_Event]) -> T_Event: ...
287

288
    @overload
289
    async def get_next_event(self, event_types: Iterable[type[Event]]) -> Event: ...
290

291
    async def get_next_event(
9✔
292
        self, event_types: type[Event] | Iterable[type[Event]]
293
    ) -> Event:
294
        """
295
        Wait until the next event matching one of the given types arrives.
296

297
        :param event_types: an event class or an iterable event classes to subscribe to
298

299
        """
300
        received_event: Event | None = None
9✔
301

302
        def receive_event(ev: Event) -> None:
9✔
303
            nonlocal received_event
304
            received_event = ev
9✔
305
            event.set()
9✔
306

307
        event = anyio.Event()
9✔
308
        with self.subscribe(receive_event, event_types, one_shot=True):
9✔
309
            await event.wait()
9✔
310
            return received_event
9✔
311

312
    async def configure_task(
9✔
313
        self,
314
        func_or_task_id: TaskType,
315
        *,
316
        func: Callable[..., Any] | UnsetValue = unset,
317
        job_executor: str | UnsetValue = unset,
318
        misfire_grace_time: float | timedelta | None | UnsetValue = unset,
319
        max_running_jobs: int | None | UnsetValue = unset,
320
        metadata: MetadataType | UnsetValue = unset,
321
    ) -> Task:
322
        """
323
        Add or update a :ref:`task <task>` definition.
324

325
        Any options not explicitly passed to this method will use their default values
326
        (from ``task_defaults``) when a new task is created:
327

328
        * ``job_executor``: the value of ``default_job_executor`` scheduler attribute
329
        * ``misfire_grace_time``: ``None``
330
        * ``max_running_jobs``: 1
331

332
        When updating a task, any options not explicitly passed will remain the same.
333

334
        If a callable is passed as the first argument, its fully qualified name will be
335
        used as the task ID.
336

337
        :param func_or_task_id: either a task, task ID or a callable
338
        :param func: a callable that will be associated with the task (can be omitted if
339
            the callable is already passed as ``func_or_task_id``)
340
        :param job_executor: name of the job executor to run the task with
341
        :param misfire_grace_time: maximum number of seconds the scheduled job's actual
342
            run time is allowed to be late, compared to the scheduled run time
343
        :param max_running_jobs: maximum number of instances of the task that are
344
            allowed to run concurrently
345
        :param metadata: key-value pairs for storing JSON compatible custom information
346
        :raises TypeError: if ``func_or_task_id`` is neither a task, task ID or a
347
            callable
348
        :return: the created or updated task definition
349

350
        """
351
        func_ref: str | None = None
9✔
352
        task: Task | None = None
9✔
353
        if callable(func_or_task_id):
9✔
354
            task_params = get_task_params(func_or_task_id)
9✔
355
            if task_params.id is unset:
9✔
356
                task_params.id = callable_to_ref(func_or_task_id)
9✔
357

358
            if func is unset:
9✔
359
                func = func_or_task_id
9✔
360
        elif isinstance(func_or_task_id, Task):
9✔
361
            task_params = TaskParameters(
×
362
                id=func_or_task_id.id,
363
                job_executor=func_or_task_id.job_executor,
364
                max_running_jobs=func_or_task_id.max_running_jobs,
365
                misfire_grace_time=func_or_task_id.misfire_grace_time,
366
                metadata=func_or_task_id.metadata,
367
            )
368
        elif isinstance(func_or_task_id, str) and func_or_task_id:
9✔
369
            try:
9✔
370
                task = await self.data_store.get_task(func_or_task_id)
9✔
371
                task_params = TaskParameters(
9✔
372
                    id=task.id,
373
                    job_executor=task.job_executor,
374
                    max_running_jobs=task.max_running_jobs,
375
                    misfire_grace_time=task.misfire_grace_time,
376
                    metadata=task.metadata,
377
                )
378
            except TaskLookupError:
9✔
379
                task_params = (
9✔
380
                    get_task_params(func) if callable(func) else TaskParameters()
381
                )
382
                task_params.id = func_or_task_id
9✔
383
        else:
384
            raise TypeError(
×
385
                "func_or_task_id must be either a task, its identifier or a callable"
386
            )
387

388
        assert task_params.id
9✔
389

390
        # Apply any settings passed directly to this function as arguments
391
        if job_executor is not unset:
9✔
392
            task_params.job_executor = job_executor
9✔
393
        if max_running_jobs is not unset:
9✔
394
            task_params.max_running_jobs = max_running_jobs
9✔
395
        if misfire_grace_time is not unset:
9✔
396
            task_params.misfire_grace_time = misfire_grace_time
9✔
397

398
        # Fill in unset values with the defaults
399
        if task_params.job_executor is unset:
9✔
400
            task_params.job_executor = self.task_defaults.job_executor
9✔
401
        if task_params.max_running_jobs is unset:
9✔
402
            task_params.max_running_jobs = self.task_defaults.max_running_jobs
9✔
403
        if task_params.misfire_grace_time is unset:
9✔
404
            task_params.misfire_grace_time = self.task_defaults.misfire_grace_time
9✔
405

406
        # Merge the metadata from the defaults, task definition and explicitly passed
407
        # metadata
408
        task_params.metadata = merge_metadata(
9✔
409
            self.task_defaults.metadata, task_params.metadata, metadata
410
        )
411

412
        if callable(func):
9✔
413
            self._task_callables[task_params.id] = func
9✔
414
            try:
9✔
415
                func_ref = callable_to_ref(func)
9✔
416
            except SerializationError:
9✔
417
                pass
9✔
418

419
        modified = False
9✔
420
        try:
9✔
421
            task = task or await self.data_store.get_task(cast(str, task_params.id))
9✔
422
        except TaskLookupError:
9✔
423
            task = Task(
9✔
424
                id=task_params.id,
425
                func=func_ref,
426
                job_executor=task_params.job_executor,
427
                max_running_jobs=task_params.max_running_jobs,
428
                misfire_grace_time=task_params.misfire_grace_time,
429
                metadata=task_params.metadata,
430
            )
431
            modified = True
9✔
432
        else:
433
            changes: dict[str, Any] = {}
9✔
434
            if func is not unset and task.func != func_ref:
9✔
435
                changes["func"] = func_ref
×
436

437
            if task_params.job_executor != task.job_executor:
9✔
438
                changes["job_executor"] = task_params.job_executor
×
439

440
            if task_params.max_running_jobs != task.max_running_jobs:
9✔
441
                changes["max_running_jobs"] = task_params.max_running_jobs
×
442

443
            if task_params.misfire_grace_time != task.misfire_grace_time:
9✔
444
                changes["misfire_grace_time"] = task_params.misfire_grace_time
9✔
445

446
            if task_params.metadata != task.metadata:
9✔
447
                changes["metadata"] = task_params.metadata
×
448

449
            if changes:
9✔
450
                task = attrs.evolve(task, **changes)
9✔
451
                modified = True
9✔
452

453
        if modified:
9✔
454
            await self.data_store.add_task(task)
9✔
455

456
        return task
9✔
457

458
    async def get_tasks(self) -> Sequence[Task]:
9✔
459
        """
460
        Retrieve all currently defined tasks.
461

462
        :return: a sequence of tasks, sorted by ID
463

464
        """
465
        self._check_initialized()
9✔
466
        return await self.data_store.get_tasks()
9✔
467

468
    async def add_schedule(
9✔
469
        self,
470
        func_or_task_id: TaskType,
471
        trigger: Trigger,
472
        *,
473
        id: str | None = None,
474
        args: Iterable[Any] | None = None,
475
        kwargs: Mapping[str, Any] | None = None,
476
        paused: bool = False,
477
        coalesce: CoalescePolicy = CoalescePolicy.latest,
478
        job_executor: str | UnsetValue = unset,
479
        misfire_grace_time: float | timedelta | None | UnsetValue = unset,
480
        metadata: MetadataType | UnsetValue = unset,
481
        max_jitter: float | timedelta | None = None,
482
        job_result_expiration_time: float | timedelta = 0,
483
        conflict_policy: ConflictPolicy = ConflictPolicy.do_nothing,
484
    ) -> str:
485
        """
486
        Schedule a task to be run one or more times in the future.
487

488
        :param func_or_task_id: either a callable or an ID of an existing task
489
            definition
490
        :param trigger: determines the times when the task should be run
491
        :param id: an explicit identifier for the schedule (if omitted, a random, UUID
492
            based ID will be assigned)
493
        :param args: positional arguments to be passed to the task function
494
        :param kwargs: keyword arguments to be passed to the task function
495
        :param paused: whether the schedule is paused
496
        :param job_executor: name of the job executor to run the scheduled jobs with
497
            (overrides the executor specified in the task settings)
498
        :param coalesce: determines what to do when processing the schedule if multiple
499
            fire times have become due for this schedule since the last processing
500
        :param misfire_grace_time: maximum number of seconds the scheduled job's actual
501
            run time is allowed to be late, compared to the scheduled run time
502
        :param metadata: key-value pairs for storing JSON compatible custom information
503
        :param max_jitter: maximum time (in seconds, or as a timedelta) to randomly add
504
            to the scheduled time for each job created from this schedule
505
        :param job_result_expiration_time: minimum time (in seconds, or as a timedelta)
506
            to keep the job results in storage from the jobs created by this schedule
507
        :param conflict_policy: determines what to do if a schedule with the same ID
508
            already exists in the data store
509
        :return: the ID of the newly added schedule
510

511
        """
512
        self._check_initialized()
9✔
513
        schedule_id = id or str(uuid4())
9✔
514
        args = tuple(args or ())
9✔
515
        kwargs = dict(kwargs or {})
9✔
516

517
        # Unpack the function and positional + keyword arguments from a partial()
518
        if isinstance(func_or_task_id, partial):
9✔
519
            args = func_or_task_id.args + args
9✔
520
            kwargs.update(func_or_task_id.keywords)
9✔
521
            func_or_task_id = func_or_task_id.func
9✔
522

523
        # For instance methods, use the unbound function as the function, and  the
524
        # "self" argument as the first positional argument
525
        if ismethod(func_or_task_id):
9✔
526
            args = (func_or_task_id.__self__, *args)
9✔
527
            func_or_task_id = func_or_task_id.__func__
9✔
528
        elif (
9✔
529
            isbuiltin(func_or_task_id)
530
            and func_or_task_id.__self__ is not None
531
            and not ismodule(func_or_task_id.__self__)
532
        ):
533
            args = (func_or_task_id.__self__, *args)
9✔
534
            method_class = type(func_or_task_id.__self__)
9✔
535
            func_or_task_id = getattr(method_class, func_or_task_id.__name__)
9✔
536

537
        task = await self.configure_task(func_or_task_id)
9✔
538
        schedule = Schedule(
9✔
539
            id=schedule_id,
540
            task_id=task.id,
541
            trigger=trigger,
542
            args=args,
543
            kwargs=kwargs,
544
            paused=paused,
545
            coalesce=coalesce,
546
            misfire_grace_time=task.misfire_grace_time
547
            if misfire_grace_time is unset
548
            else misfire_grace_time,
549
            metadata=task.metadata.copy()
550
            if metadata is unset
551
            else merge_metadata(task.metadata, metadata),
552
            max_jitter=max_jitter,
553
            job_executor=task.job_executor if job_executor is unset else job_executor,
554
            job_result_expiration_time=job_result_expiration_time,
555
        )
556
        schedule.next_fire_time = trigger.next()
9✔
557
        await self.data_store.add_schedule(schedule, conflict_policy)
9✔
558
        self.logger.info(
9✔
559
            "Added new schedule (task=%r, trigger=%r); next run time at %s",
560
            task.id,
561
            trigger,
562
            schedule.next_fire_time,
563
        )
564
        return schedule.id
9✔
565

566
    async def get_schedule(self, id: str) -> Schedule:
9✔
567
        """
568
        Retrieve a schedule from the data store.
569

570
        :param id: the unique identifier of the schedule
571
        :raises ScheduleLookupError: if the schedule could not be found
572

573
        """
574
        self._check_initialized()
9✔
575
        schedules = await self.data_store.get_schedules({id})
9✔
576
        if schedules:
9✔
577
            return schedules[0]
9✔
578
        else:
579
            raise ScheduleLookupError(id)
9✔
580

581
    async def get_schedules(self) -> list[Schedule]:
9✔
582
        """
583
        Retrieve all schedules from the data store.
584

585
        :return: a list of schedules, in an unspecified order
586

587
        """
588
        self._check_initialized()
9✔
589
        return await self.data_store.get_schedules()
9✔
590

591
    async def remove_schedule(self, id: str) -> None:
9✔
592
        """
593
        Remove the given schedule from the data store.
594

595
        :param id: the unique identifier of the schedule
596

597
        """
598
        self._check_initialized()
9✔
599
        await self.data_store.remove_schedules({id})
9✔
600

601
    async def pause_schedule(self, id: str) -> None:
9✔
602
        """Pause the specified schedule."""
603
        self._check_initialized()
9✔
604
        await self.data_store.add_schedule(
9✔
605
            schedule=attrs.evolve(await self.get_schedule(id), paused=True),
606
            conflict_policy=ConflictPolicy.replace,
607
        )
608

609
    async def unpause_schedule(
9✔
610
        self,
611
        id: str,
612
        *,
613
        resume_from: datetime | Literal["now"] | None = None,
614
    ) -> None:
615
        """
616
        Unpause the specified schedule.
617

618

619
        :param resume_from: the time to resume the schedules from, or ``'now'`` as a
620
            shorthand for ``datetime.now(tz=UTC)`` or ``None`` to resume from where the
621
            schedule left off which may cause it to misfire
622

623
        """
624
        self._check_initialized()
9✔
625
        schedule = await self.get_schedule(id)
9✔
626

627
        if resume_from == "now":
9✔
628
            resume_from = datetime.now(tz=timezone.utc)
×
629

630
        if resume_from is None:
9✔
631
            next_fire_time = schedule.next_fire_time
9✔
632
        elif (
×
633
            schedule.next_fire_time is not None
634
            and schedule.next_fire_time >= resume_from
635
        ):
636
            next_fire_time = schedule.next_fire_time
×
637
        else:
638
            # Advance `next_fire_time` until its at or past `resume_from`, or until it's
639
            # exhausted
640
            while next_fire_time := schedule.trigger.next():
×
641
                if next_fire_time is None or next_fire_time >= resume_from:
×
642
                    break
×
643

644
        await self.data_store.add_schedule(
9✔
645
            schedule=attrs.evolve(
646
                schedule,
647
                paused=False,
648
                next_fire_time=next_fire_time,
649
            ),
650
            conflict_policy=ConflictPolicy.replace,
651
        )
652

653
    async def add_job(
9✔
654
        self,
655
        func_or_task_id: TaskType,
656
        *,
657
        args: Iterable[Any] | None = None,
658
        kwargs: Mapping[str, Any] | None = None,
659
        job_executor: str | UnsetValue = unset,
660
        metadata: MetadataType | UnsetValue = unset,
661
        result_expiration_time: timedelta | float = 0,
662
    ) -> UUID:
663
        """
664
        Add a job to the data store.
665

666
        :param func_or_task_id:
667
            Either the ID of a pre-existing task, or a function/method. If a function is
668
            given, a task will be created with the fully qualified name of the function
669
            as the task ID (unless that task already exists of course).
670
        :param args: positional arguments to call the target callable with
671
        :param kwargs: keyword arguments to call the target callable with
672
        :param job_executor: name of the job executor to run the task with
673
            (overrides the executor in the task definition, if any)
674
        :param metadata: key-value pairs for storing JSON compatible custom information
675
        :param result_expiration_time: the minimum time (as seconds, or timedelta) to
676
            keep the result of the job available for fetching (the result won't be
677
            saved at all if that time is 0)
678
        :return: the ID of the newly created job
679

680
        """
681
        self._check_initialized()
9✔
682
        args = tuple(args or ())
9✔
683
        kwargs = dict(kwargs or {})
9✔
684

685
        # Unpack the function and positional + keyword arguments from a partial()
686
        if isinstance(func_or_task_id, partial):
9✔
687
            args = func_or_task_id.args + args
9✔
688
            kwargs.update(func_or_task_id.keywords)
9✔
689
            func_or_task_id = func_or_task_id.func
9✔
690

691
        # For instance methods, use the unbound function as the function, and  the
692
        # "self" argument as the first positional argument
693
        if ismethod(func_or_task_id):
9✔
694
            args = (func_or_task_id.__self__, *args)
9✔
695
            func_or_task_id = func_or_task_id.__func__
9✔
696
        elif (
9✔
697
            isbuiltin(func_or_task_id)
698
            and func_or_task_id.__self__ is not None
699
            and not ismodule(func_or_task_id.__self__)
700
        ):
701
            args = (func_or_task_id.__self__, *args)
9✔
702
            method_class = type(func_or_task_id.__self__)
9✔
703
            func_or_task_id = getattr(method_class, func_or_task_id.__name__)
9✔
704

705
        task = await self.configure_task(func_or_task_id)
9✔
706
        job = Job(
9✔
707
            task_id=task.id,
708
            args=args or (),
709
            kwargs=kwargs or {},
710
            executor=task.job_executor if job_executor is unset else job_executor,
711
            result_expiration_time=result_expiration_time,
712
            metadata=merge_metadata(task.metadata, metadata),
713
        )
714
        await self.data_store.add_job(job)
9✔
715
        return job.id
9✔
716

717
    async def get_jobs(self) -> Sequence[Job]:
9✔
718
        """Retrieve all jobs from the data store."""
719
        self._check_initialized()
9✔
720
        return await self.data_store.get_jobs()
9✔
721

722
    async def get_job_result(
9✔
723
        self, job_id: UUID, *, wait: bool = True
724
    ) -> JobResult | None:
725
        """
726
        Retrieve the result of a job.
727

728
        :param job_id: the ID of the job
729
        :param wait: if ``True``, wait until the job has ended (one way or another),
730
            ``False`` to raise an exception if the result is not yet available
731
        :returns: the job result, or ``None`` if the job finished but didn't record a
732
            result (``result_expiration_time`` was 0 or a similarly short time interval
733
            that did not allow for the result to be fetched before it was deleted)
734
        :raises JobLookupError: if neither the job or its result exist in the data
735
            store, or the job exists but the result is not ready yet and ``wait=False``
736
            is set
737

738
        """
739
        self._check_initialized()
9✔
740
        wait_event = anyio.Event()
9✔
741

742
        def listener(event: JobReleased) -> None:
9✔
743
            if event.job_id == job_id:
9✔
744
                wait_event.set()
9✔
745

746
        with self.event_broker.subscribe(listener, {JobReleased}):
9✔
747
            job_exists = bool(await self.data_store.get_jobs([job_id]))
9✔
748
            if result := await self.data_store.get_job_result(job_id):
9✔
749
                return result
9✔
750

751
            if job_exists and wait:
9✔
752
                await wait_event.wait()
9✔
753
            else:
754
                raise JobLookupError(job_id)
9✔
755

756
        return await self.data_store.get_job_result(job_id)
9✔
757

758
    async def run_job(
9✔
759
        self,
760
        func_or_task_id: str | Callable[..., Any],
761
        *,
762
        args: Iterable[Any] | None = None,
763
        kwargs: Mapping[str, Any] | None = None,
764
        job_executor: str | UnsetValue = unset,
765
        metadata: MetadataType | UnsetValue = unset,
766
    ) -> Any:
767
        """
768
        Convenience method to add a job and then return its result.
769

770
        If the job raised an exception, that exception will be reraised here.
771

772
        :param func_or_task_id: either a callable or an ID of an existing task
773
            definition
774
        :param args: positional arguments to be passed to the task function
775
        :param kwargs: keyword arguments to be passed to the task function
776
        :param job_executor: name of the job executor to run the task with
777
            (overrides the executor in the task definition, if any)
778
        :param metadata: key-value pairs for storing JSON compatible custom information
779
        :returns: the return value of the task function
780

781
        """
782
        self._check_initialized()
9✔
783
        job_complete_event = anyio.Event()
9✔
784

785
        def listener(event: JobReleased) -> None:
9✔
786
            if event.job_id == job_id:
9✔
787
                job_complete_event.set()
9✔
788

789
        job_id: UUID | None = None
9✔
790
        with self.event_broker.subscribe(listener, {JobReleased}):
9✔
791
            job_id = await self.add_job(
9✔
792
                func_or_task_id,
793
                args=args,
794
                kwargs=kwargs,
795
                job_executor=job_executor,
796
                metadata=metadata,
797
                result_expiration_time=timedelta(minutes=15),
798
            )
799
            await job_complete_event.wait()
9✔
800

801
        result = await self.get_job_result(job_id)
9✔
802
        if result is None:
9✔
803
            raise RuntimeError(
×
804
                "Job completed but job result not found - report this as a bug!"
805
            )
806

807
        if result.exception:
9✔
808
            assert result.outcome is JobOutcome.error
9✔
809
            raise result.exception
9✔
810

811
        if result.outcome is JobOutcome.success:
9✔
812
            return result.return_value
9✔
813
        elif result.outcome is JobOutcome.missed_start_deadline:
×
814
            raise JobDeadlineMissed
×
815
        elif result.outcome is JobOutcome.cancelled:
×
816
            raise JobCancelled
×
817
        else:
818
            raise RuntimeError(f"Unknown job outcome: {result.outcome}")
×
819

820
    async def stop(self) -> None:
9✔
821
        """
822
        Signal the scheduler that it should stop processing schedules.
823

824
        This method does not wait for the scheduler to actually stop.
825
        For that, see :meth:`wait_until_stopped`.
826

827
        """
828
        if self._state is RunState.started and self._scheduler_cancel_scope:
9✔
829
            self._state = RunState.stopping
9✔
830
            self._scheduler_cancel_scope.cancel()
9✔
831

832
    async def wait_until_stopped(self) -> None:
9✔
833
        """
834
        Wait until the scheduler is in the :attr:`~RunState.stopped` or
835
        :attr:`~RunState.stopping` state.
836

837
        If the scheduler is already stopped or in the process of stopping, this method
838
        returns immediately. Otherwise, it waits until the scheduler posts the
839
        :class:`SchedulerStopped` event.
840

841
        """
842
        if self._state not in (RunState.stopped, RunState.stopping):
9✔
843
            await self.get_next_event(SchedulerStopped)
9✔
844

845
    async def start_in_background(self) -> None:
9✔
846
        self._check_initialized()
9✔
847
        await self._services_task_group.start(
9✔
848
            self.run_until_stopped,
849
            name=f"Scheduler {self.identity!r} main task",
850
        )
851

852
    async def run_until_stopped(
9✔
853
        self, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED
854
    ) -> None:
855
        """Run the scheduler until explicitly stopped."""
856
        if self._state is not RunState.stopped:
9✔
857
            raise RuntimeError(
×
858
                f'Cannot start the scheduler when it is in the "{self._state}" state'
859
            )
860

861
        self._state = RunState.starting
9✔
862
        async with AsyncExitStack() as exit_stack:
9✔
863
            await self._ensure_services_initialized(exit_stack)
9✔
864

865
            # Set this scheduler as the current scheduler
866
            token = current_async_scheduler.set(self)
9✔
867
            exit_stack.callback(current_async_scheduler.reset, token)
9✔
868

869
            exception: BaseException | None = None
9✔
870
            try:
9✔
871
                async with create_task_group() as task_group:
9✔
872
                    self._scheduler_cancel_scope = task_group.cancel_scope
9✔
873
                    exit_stack.callback(setattr, self, "_scheduler_cancel_scope", None)
9✔
874

875
                    # Start periodic cleanups
876
                    if self.cleanup_interval:
9✔
877
                        task_group.start_soon(
9✔
878
                            self._cleanup_loop,
879
                            name=f"Scheduler {self.identity!r} clean-up loop",
880
                        )
881
                        self.logger.debug(
9✔
882
                            "Started internal cleanup loop with interval: %s",
883
                            self.cleanup_interval,
884
                        )
885

886
                    # Start processing due schedules, if configured to do so
887
                    if self.role in (SchedulerRole.scheduler, SchedulerRole.both):
9✔
888
                        await task_group.start(
9✔
889
                            self._process_schedules,
890
                            name=f"Scheduler {self.identity!r} schedule processing loop",
891
                        )
892

893
                    # Start processing due jobs, if configured to do so
894
                    if self.role in (SchedulerRole.worker, SchedulerRole.both):
9✔
895
                        await task_group.start(
9✔
896
                            self._process_jobs,
897
                            name=f"Scheduler {self.identity!r} job processing loop",
898
                        )
899

900
                    # Signal that the scheduler has started
901
                    self._state = RunState.started
9✔
902
                    self.logger.info("Scheduler started")
9✔
903
                    task_status.started()
9✔
904
                    await self.event_broker.publish_local(SchedulerStarted())
9✔
905
            except BaseException as exc:
9✔
906
                exception = exc
9✔
907
                raise
9✔
908
            finally:
909
                self._state = RunState.stopped
9✔
910

911
                if not exception or isinstance(exception, get_cancelled_exc_class()):
9✔
912
                    self.logger.info("Scheduler stopped")
9✔
913
                elif isinstance(exception, Exception):
9✔
914
                    self.logger.exception("Scheduler crashed")
9✔
915
                elif exception:
4✔
916
                    self.logger.info(
4✔
917
                        "Scheduler stopped due to %s", exception.__class__.__name__
918
                    )
919

920
                with move_on_after(3, shield=True):
9✔
921
                    await self.event_broker.publish_local(
9✔
922
                        SchedulerStopped(exception=exception)
923
                    )
924

925
    async def _process_schedules(self, *, task_status: TaskStatus[None]) -> None:
9✔
926
        wakeup_event = anyio.Event()
9✔
927
        wakeup_deadline: datetime | None = None
9✔
928

929
        async def schedule_added_or_modified(event: Event) -> None:
9✔
930
            event_ = cast("ScheduleAdded | ScheduleUpdated", event)
9✔
931
            if not wakeup_deadline or (
9✔
932
                event_.next_fire_time and event_.next_fire_time < wakeup_deadline
933
            ):
934
                self.logger.debug(
9✔
935
                    "Detected a %s event – waking up the scheduler to process "
936
                    "schedules",
937
                    type(event).__name__,
938
                )
939
                wakeup_event.set()
9✔
940

941
        async def extend_schedule_leases(schedules: Sequence[Schedule]) -> None:
9✔
942
            schedule_ids = {schedule.id for schedule in schedules}
9✔
943
            while True:
9✔
944
                await sleep(self.lease_duration.total_seconds() / 2)
9✔
945
                await self.data_store.extend_acquired_schedule_leases(
×
946
                    self.identity, schedule_ids, self.lease_duration
947
                )
948

949
        subscription = self.event_broker.subscribe(
9✔
950
            schedule_added_or_modified, {ScheduleAdded, ScheduleUpdated}
951
        )
952
        with subscription:
9✔
953
            # Signal that we are ready, and wait for the scheduler start event
954
            task_status.started()
9✔
955
            await self.get_next_event(SchedulerStarted)
9✔
956

957
            while self._state is RunState.started:
9✔
958
                schedules = await self.data_store.acquire_schedules(
9✔
959
                    self.identity, self.lease_duration, 100
960
                )
961
                async with AsyncExitStack() as exit_stack:
9✔
962
                    tg = await exit_stack.enter_async_context(create_task_group())
9✔
963
                    tg.start_soon(
9✔
964
                        extend_schedule_leases,
965
                        schedules,
966
                        name=(
967
                            f"Scheduler {self.identity!r} schedule lease extension loop"
968
                        ),
969
                    )
970
                    exit_stack.callback(tg.cancel_scope.cancel)
9✔
971

972
                    now = datetime.now(timezone.utc)
9✔
973
                    results: list[ScheduleResult] = []
9✔
974
                    for schedule in schedules:
9✔
975
                        # Calculate a next fire time for the schedule, if possible
976
                        fire_times = [schedule.next_fire_time]
9✔
977
                        calculate_next = schedule.trigger.next
9✔
978
                        while True:
9✔
979
                            try:
9✔
980
                                fire_time = calculate_next()
9✔
981
                            except Exception:
×
982
                                self.logger.exception(
4✔
983
                                    "Error computing next fire time for schedule %r of "
984
                                    "task %r – removing schedule",
985
                                    schedule.id,
986
                                    schedule.task_id,
987
                                )
988
                                break
3✔
989

990
                            # Stop if the calculated fire time is in the future
991
                            if fire_time is None or fire_time > now:
9✔
992
                                next_fire_time = fire_time
9✔
993
                                break
9✔
994

995
                            # Only keep all the fire times if coalesce policy = "all"
996
                            if schedule.coalesce is CoalescePolicy.all:
9✔
997
                                fire_times.append(fire_time)
9✔
998
                            elif schedule.coalesce is CoalescePolicy.latest:
9✔
999
                                fire_times[0] = fire_time
9✔
1000

1001
                        # Add one or more jobs to the job queue
1002
                        max_jitter = (
9✔
1003
                            schedule.max_jitter.total_seconds()
1004
                            if schedule.max_jitter
1005
                            else 0
1006
                        )
1007
                        for i, fire_time in enumerate(fire_times):
9✔
1008
                            # Calculate a jitter if max_jitter > 0
1009
                            jitter = _zero_timedelta
9✔
1010
                            if max_jitter:
9✔
1011
                                if i + 1 < len(fire_times):
9✔
1012
                                    following_fire_time = fire_times[i + 1]
6✔
1013
                                else:
1014
                                    following_fire_time = next_fire_time
9✔
1015

1016
                                if following_fire_time is not None:
9✔
1017
                                    # Jitter must never be so high that it would cause a
1018
                                    # fire time to equal or exceed the next fire time
1019
                                    max_jitter = min(
×
1020
                                        [
1021
                                            max_jitter,
1022
                                            (
1023
                                                following_fire_time
1024
                                                - fire_time
1025
                                                - _microsecond_delta
1026
                                            ).total_seconds(),
1027
                                        ]
1028
                                    )
1029

1030
                                jitter = timedelta(
9✔
1031
                                    seconds=random.uniform(0, max_jitter)
1032
                                )
1033
                                fire_time += jitter
9✔
1034

1035
                            if schedule.misfire_grace_time is None:
9✔
1036
                                start_deadline: datetime | None = None
9✔
1037
                            else:
1038
                                start_deadline = fire_time + schedule.misfire_grace_time
9✔
1039

1040
                            job = Job(
9✔
1041
                                task_id=schedule.task_id,
1042
                                args=schedule.args,
1043
                                kwargs=schedule.kwargs,
1044
                                schedule_id=schedule.id,
1045
                                scheduled_fire_time=fire_time,
1046
                                jitter=jitter,
1047
                                start_deadline=start_deadline,
1048
                                executor=schedule.job_executor,
1049
                                result_expiration_time=schedule.job_result_expiration_time,
1050
                                metadata=schedule.metadata.copy(),
1051
                            )
1052
                            await self.data_store.add_job(job)
9✔
1053

1054
                        results.append(
9✔
1055
                            ScheduleResult(
1056
                                schedule_id=schedule.id,
1057
                                task_id=schedule.task_id,
1058
                                trigger=schedule.trigger,
1059
                                last_fire_time=fire_times[-1],
1060
                                next_fire_time=next_fire_time,
1061
                            )
1062
                        )
1063

1064
                # Update the schedules (and release the scheduler's claim on them)
1065
                await self.data_store.release_schedules(self.identity, results)
9✔
1066

1067
                # If we received fewer schedules than the maximum amount, sleep
1068
                # until the next schedule is due or the scheduler is explicitly
1069
                # woken up
1070
                wait_time = None
9✔
1071
                if len(schedules) < 100:
9✔
1072
                    wakeup_deadline = await self.data_store.get_next_schedule_run_time()
9✔
1073
                    if wakeup_deadline:
9✔
1074
                        wait_time = (
9✔
1075
                            wakeup_deadline - datetime.now(timezone.utc)
1076
                        ).total_seconds()
1077
                        self.logger.debug(
9✔
1078
                            "Sleeping %.3f seconds until the next fire time (%s)",
1079
                            wait_time,
1080
                            wakeup_deadline,
1081
                        )
1082
                    else:
1083
                        self.logger.debug("Waiting for any due schedules to appear")
9✔
1084

1085
                    with move_on_after(wait_time):
9✔
1086
                        await wakeup_event.wait()
9✔
1087
                        wakeup_event = anyio.Event()
9✔
1088
                else:
1089
                    self.logger.debug("Processing more schedules on the next iteration")
×
1090

1091
    def _get_task_callable(self, task: Task) -> Callable:
9✔
1092
        try:
9✔
1093
            return self._task_callables[task.id]
9✔
NEW
1094
        except KeyError as exc:
×
1095
            if task.func:
×
1096
                try:
×
1097
                    func = self._task_callables[task.id] = callable_from_ref(task.func)
×
1098
                except DeserializationError as exc:
×
1099
                    raise CallableLookupError(
×
1100
                        f"Error looking up the callable ({task.func!r}) for task "
1101
                        f"{task.id!r}"
1102
                    ) from exc
1103

1104
                return func
×
1105

1106
            raise CallableLookupError(
×
1107
                f"Task {task.id} requires a locally defined callable to be run, but no "
1108
                f"such callable has been defined. Call "
1109
                f"scheduler.configure_task({task.id!r}, func=...) to define the local "
1110
                f"callable."
1111
            ) from exc
1112

1113
    async def _process_jobs(self, *, task_status: TaskStatus[None]) -> None:
9✔
1114
        wakeup_event = anyio.Event()
9✔
1115

1116
        async def check_queue_capacity(event: Event) -> None:
9✔
1117
            if len(self._running_jobs) < self.max_concurrent_jobs:
9✔
1118
                wakeup_event.set()
9✔
1119

1120
        async def extend_job_leases() -> None:
9✔
1121
            while self._state in (RunState.starting, RunState.started):
9✔
1122
                await sleep(self.lease_duration.total_seconds() / 2)
9✔
1123
                if job_ids := {job.id for job in self._running_jobs}:
×
1124
                    await self.data_store.extend_acquired_job_leases(
×
1125
                        self.identity, job_ids, self.lease_duration
1126
                    )
1127

1128
        # If there are any jobs marked as being acquired by this scheduler, release them
1129
        # with the "abandoned" outcome right away
1130
        await self.data_store.reap_abandoned_jobs(self.identity)
9✔
1131

1132
        async with AsyncExitStack() as exit_stack:
9✔
1133
            # Start the job executors
1134
            for job_executor in self.job_executors.values():
9✔
1135
                await job_executor.start(exit_stack)
9✔
1136

1137
            task_group = await exit_stack.enter_async_context(create_task_group())
9✔
1138
            task_group.start_soon(
9✔
1139
                extend_job_leases,
1140
                name=f"Scheduler {self.identity!r} job lease extension loop",
1141
            )
1142

1143
            # Fetch new jobs every time
1144
            exit_stack.enter_context(
9✔
1145
                self.event_broker.subscribe(
1146
                    check_queue_capacity, {JobAdded, JobReleased}
1147
                )
1148
            )
1149

1150
            # Signal that we are ready, and wait for the scheduler start event
1151
            task_status.started()
9✔
1152
            await self.get_next_event(SchedulerStarted)
9✔
1153

1154
            while self._state is RunState.started:
9✔
1155
                limit = self.max_concurrent_jobs - len(self._running_jobs)
9✔
1156
                if limit > 0:
9✔
1157
                    jobs = await self.data_store.acquire_jobs(
9✔
1158
                        self.identity, self.lease_duration, limit
1159
                    )
1160
                    for job in jobs:
9✔
1161
                        task = await self.data_store.get_task(job.task_id)
9✔
1162
                        func = self._get_task_callable(task)
9✔
1163
                        self._running_jobs.add(job)
9✔
1164
                        task_group.start_soon(
9✔
1165
                            self._run_job,
1166
                            job,
1167
                            func,
1168
                            job.executor,
1169
                            name=(
1170
                                f"Scheduler {self.identity!r} job {job.id} "
1171
                                f"({job.executor!r})"
1172
                            ),
1173
                        )
1174

1175
                await wakeup_event.wait()
9✔
1176
                wakeup_event = anyio.Event()
9✔
1177

1178
    async def _run_job(self, job: Job, func: Callable[..., Any], executor: str) -> None:
9✔
1179
        try:
9✔
1180
            # Check if the job started before the deadline
1181
            start_time = datetime.now(timezone.utc)
9✔
1182
            if job.start_deadline is not None and start_time > job.start_deadline:
9✔
1183
                result = JobResult.from_job(
×
1184
                    job, JobOutcome.missed_start_deadline, finished_at=start_time
1185
                )
1186
                await self.data_store.release_job(self.identity, job, result)
×
1187
                return
×
1188

1189
            try:
9✔
1190
                job_executor = self.job_executors[executor]
9✔
1191
            except KeyError:
×
1192
                return
×
1193

1194
            token = current_job.set(job)
9✔
1195
            try:
9✔
1196
                retval = await job_executor.run_job(func, job)
9✔
1197
            except get_cancelled_exc_class():
9✔
1198
                self.logger.info("Job %s was cancelled", job.id)
9✔
1199
                with CancelScope(shield=True):
9✔
1200
                    result = JobResult.from_job(
9✔
1201
                        job, JobOutcome.cancelled, started_at=start_time
1202
                    )
1203
                    await self.data_store.release_job(self.identity, job, result)
9✔
1204
            except BaseException as exc:
9✔
1205
                if isinstance(exc, Exception):
9✔
1206
                    self.logger.exception("Job %s raised an exception", job.id)
9✔
1207
                else:
1208
                    self.logger.error(
×
1209
                        "Job %s was aborted due to %s", job.id, exc.__class__.__name__
1210
                    )
1211

1212
                result = JobResult.from_job(
9✔
1213
                    job,
1214
                    JobOutcome.error,
1215
                    started_at=start_time,
1216
                    exception=exc,
1217
                )
1218
                await self.data_store.release_job(self.identity, job, result)
9✔
1219
                if not isinstance(exc, Exception):
9✔
1220
                    raise
1✔
1221
            else:
1222
                self.logger.info("Job %s completed successfully", job.id)
9✔
1223
                result = JobResult.from_job(
9✔
1224
                    job,
1225
                    JobOutcome.success,
1226
                    started_at=start_time,
1227
                    return_value=retval,
1228
                )
1229
                await self.data_store.release_job(self.identity, job, result)
9✔
1230
            finally:
1231
                current_job.reset(token)
9✔
1232
        finally:
1233
            self._running_jobs.remove(job)
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc