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

agronholm / apscheduler / 20108598472

10 Dec 2025 06:06PM UTC coverage: 92.575% (-0.2%) from 92.793%
20108598472

push

github

agronholm
Removed unconditional import of sniffio

Sniffio is no longer an implicit dependency, as of AnyIO 4.12.0.

Fixes #1093.

8 of 15 new or added lines in 2 files covered. (53.33%)

7 existing lines in 2 files now uncovered.

3491 of 3771 relevant lines covered (92.57%)

7.67 hits per line

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

94.48
/src/apscheduler/datastores/sqlalchemy.py
1
from __future__ import annotations
9✔
2

3
from collections import defaultdict
9✔
4
from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence
9✔
5
from contextlib import AsyncExitStack, asynccontextmanager
9✔
6
from datetime import datetime, timedelta, timezone
9✔
7
from functools import partial
9✔
8
from logging import Logger
9✔
9
from typing import Any, cast
9✔
10
from uuid import UUID
9✔
11

12
import anyio
9✔
13
import attrs
9✔
14
import tenacity
9✔
15
from anyio import CancelScope, to_thread
9✔
16
from attr.validators import instance_of
9✔
17
from sqlalchemy import (
9✔
18
    JSON,
19
    BigInteger,
20
    Boolean,
21
    Column,
22
    DateTime,
23
    Enum,
24
    Integer,
25
    Interval,
26
    LargeBinary,
27
    MetaData,
28
    SmallInteger,
29
    Table,
30
    TypeDecorator,
31
    Unicode,
32
    Uuid,
33
    and_,
34
    bindparam,
35
    create_engine,
36
    false,
37
    or_,
38
    select,
39
)
40
from sqlalchemy.engine import URL, Dialect, Result
9✔
41
from sqlalchemy.exc import (
9✔
42
    CompileError,
43
    IntegrityError,
44
    InterfaceError,
45
    InvalidRequestError,
46
    ProgrammingError,
47
)
48
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
9✔
49
from sqlalchemy.future import Connection, Engine
9✔
50
from sqlalchemy.schema import CreateSchema
9✔
51
from sqlalchemy.sql import Executable
9✔
52
from sqlalchemy.sql.ddl import DropTable
9✔
53
from sqlalchemy.sql.elements import BindParameter, literal
9✔
54
from sqlalchemy.sql.type_api import TypeEngine
9✔
55

56
from .._enums import CoalescePolicy, ConflictPolicy, JobOutcome
9✔
57
from .._events import (
9✔
58
    DataStoreEvent,
59
    Event,
60
    JobAcquired,
61
    JobAdded,
62
    JobDeserializationFailed,
63
    JobReleased,
64
    ScheduleAdded,
65
    ScheduleDeserializationFailed,
66
    ScheduleRemoved,
67
    ScheduleUpdated,
68
    TaskAdded,
69
    TaskRemoved,
70
    TaskUpdated,
71
)
72
from .._exceptions import (
9✔
73
    ConflictingIdError,
74
    DeserializationError,
75
    SerializationError,
76
    TaskLookupError,
77
)
78
from .._structures import Job, JobResult, Schedule, ScheduleResult, Task
9✔
79
from .._utils import create_repr, current_async_library
9✔
80
from ..abc import EventBroker
9✔
81
from .base import BaseExternalDataStore
9✔
82

83

84
class EmulatedTimestampTZ(TypeDecorator[datetime]):
9✔
85
    impl = Unicode(32)
9✔
86
    cache_ok = True
9✔
87

88
    def process_bind_param(
9✔
89
        self, value: datetime | None, dialect: Dialect
90
    ) -> str | None:
91
        return value.isoformat() if value is not None else None
9✔
92

93
    def process_result_value(
9✔
94
        self, value: str | None, dialect: Dialect
95
    ) -> datetime | None:
96
        return datetime.fromisoformat(value) if value is not None else None
9✔
97

98

99
class EmulatedInterval(TypeDecorator[timedelta]):
9✔
100
    impl = BigInteger()
9✔
101
    cache_ok = True
9✔
102

103
    def process_bind_param(
9✔
104
        self, value: timedelta | None, dialect: Dialect
105
    ) -> float | None:
106
        return value.total_seconds() * 1000000 if value is not None else None
9✔
107

108
    def process_result_value(
9✔
109
        self, value: int | None, dialect: Dialect
110
    ) -> timedelta | None:
111
        return timedelta(seconds=value / 1000000) if value is not None else None
9✔
112

113

114
def marshal_timestamp(timestamp: datetime | None, key: str) -> Mapping[str, Any]:
9✔
115
    if timestamp is None:
9✔
116
        return {key: None, key + "_utcoffset": None}
9✔
117

118
    return {
9✔
119
        key: int(timestamp.timestamp() * 1000_000),
120
        key + "_utcoffset": cast(timedelta, timestamp.utcoffset()).total_seconds()
121
        // 60,
122
    }
123

124

125
@attrs.define(eq=False, frozen=True)
9✔
126
class _JobDiscard:
9✔
127
    job_id: UUID
9✔
128
    outcome: JobOutcome
9✔
129
    task_id: str
9✔
130
    schedule_id: str | None
9✔
131
    scheduled_fire_time: datetime | None
9✔
132
    result_expires_at: datetime
9✔
133
    exception: Exception | None = None
9✔
134

135

136
@attrs.define(eq=False, repr=False)
9✔
137
class SQLAlchemyDataStore(BaseExternalDataStore):
9✔
138
    """
139
    Uses a relational database to store data.
140

141
    When started, this data store creates the appropriate tables on the given database
142
    if they're not already present.
143

144
    Operations are retried (in accordance to ``retry_settings``) when an operation
145
    raises either :exc:`OSError` or :exc:`sqlalchemy.exc.InterfaceError`.
146

147
    This store has been tested to work with:
148

149
     * PostgreSQL (asyncpg and psycopg drivers)
150
     * MySQL (asyncmy driver)
151
     * aiosqlite (not recommended right now, as issues like
152
       `#1032 <https://github.com/agronholm/apscheduler/issues/1032>`_ exist)
153

154
    :param engine_or_url: a SQLAlchemy URL or engine (preferably asynchronous, but can
155
        be synchronous)
156
    :param schema: a database schema name to use, if not the default
157

158
    .. note:: The data store will not manage the life cycle of any engine instance
159
        passed to it, so you need to close the engine afterwards when you're done with
160
        it.
161

162
    .. warning:: Do not use SQLite when sharing the data store with multiple schedulers,
163
        as there is an unresolved issue with that
164
        (`#959 <https://github.com/agronholm/apscheduler/issues/959>`_).
165
    """
166

167
    engine_or_url: str | URL | Engine | AsyncEngine = attrs.field(
9✔
168
        validator=instance_of((str, URL, Engine, AsyncEngine))
169
    )
170
    schema: str | None = attrs.field(kw_only=True, default=None)
9✔
171

172
    _engine: Engine | AsyncEngine = attrs.field(init=False)
9✔
173
    _close_on_exit: bool = attrs.field(init=False, default=False)
9✔
174
    _supports_update_returning: bool = attrs.field(init=False, default=False)
9✔
175
    _supports_tzaware_timestamps: bool = attrs.field(init=False, default=False)
9✔
176
    _supports_native_interval: bool = attrs.field(init=False, default=False)
9✔
177
    _metadata: MetaData = attrs.field(init=False)
9✔
178
    _t_metadata: Table = attrs.field(init=False)
9✔
179
    _t_tasks: Table = attrs.field(init=False)
9✔
180
    _t_schedules: Table = attrs.field(init=False)
9✔
181
    _t_jobs: Table = attrs.field(init=False)
9✔
182
    _t_job_results: Table = attrs.field(init=False)
9✔
183

184
    def __attrs_post_init__(self) -> None:
9✔
185
        if isinstance(self.engine_or_url, (str, URL)):
9✔
186
            try:
×
187
                self._engine = create_async_engine(self.engine_or_url)
×
188
            except InvalidRequestError:
×
189
                self._engine = create_engine(self.engine_or_url)
×
190

191
            self._close_on_exit = True
6✔
192
        else:
193
            self._engine = self.engine_or_url
9✔
194

195
        # Generate the table definitions
196
        prefix = f"{self.schema}." if self.schema else ""
9✔
197
        self._supports_tzaware_timestamps = self._engine.dialect.name in (
9✔
198
            "postgresql",
199
            "oracle",
200
        )
201
        self._supports_native_interval = self._engine.dialect.name == "postgresql"
9✔
202
        self._metadata = self.get_table_definitions()
9✔
203
        self._t_metadata = self._metadata.tables[prefix + "metadata"]
9✔
204
        self._t_tasks = self._metadata.tables[prefix + "tasks"]
9✔
205
        self._t_schedules = self._metadata.tables[prefix + "schedules"]
9✔
206
        self._t_jobs = self._metadata.tables[prefix + "jobs"]
9✔
207
        self._t_job_results = self._metadata.tables[prefix + "job_results"]
9✔
208

209
    def __repr__(self) -> str:
9✔
210
        return create_repr(self, url=repr(self._engine.url), schema=self.schema)
9✔
211

212
    def _retry(self) -> tenacity.AsyncRetrying:
9✔
213
        def after_attempt(retry_state: tenacity.RetryCallState) -> None:
9✔
214
            self._logger.warning(
×
215
                "Temporary data store error (attempt %d): %s",
216
                retry_state.attempt_number,
217
                retry_state.outcome.exception(),
218
            )
219

220
        # OSError is raised by asyncpg if it can't connect
221
        return tenacity.AsyncRetrying(
9✔
222
            stop=self.retry_settings.stop,
223
            wait=self.retry_settings.wait,
224
            retry=tenacity.retry_if_exception_type((InterfaceError, OSError)),
225
            after=after_attempt,
226
            sleep=anyio.sleep,
227
            reraise=True,
228
        )
229

230
    @asynccontextmanager
9✔
231
    async def _begin_transaction(
9✔
232
        self,
233
    ) -> AsyncGenerator[Connection | AsyncConnection, None]:
234
        # A shielded cancel scope is injected to the exit stack to allow finalization
235
        # to occur even when the surrounding cancel scope is cancelled
236
        async with AsyncExitStack() as exit_stack:
9✔
237
            if isinstance(self._engine, AsyncEngine):
9✔
238
                async_cm = self._engine.begin()
9✔
239
                conn = await async_cm.__aenter__()
9✔
240
                exit_stack.enter_context(CancelScope(shield=True))
9✔
241
                exit_stack.push_async_exit(async_cm.__aexit__)
9✔
242
            else:
243
                cm = self._engine.begin()
5✔
244
                conn = await to_thread.run_sync(cm.__enter__)
5✔
245
                exit_stack.enter_context(CancelScope(shield=True))
5✔
246
                exit_stack.push_async_exit(partial(to_thread.run_sync, cm.__exit__))
5✔
247

248
            yield conn
9✔
249

250
    async def _create_metadata(self, conn: Connection | AsyncConnection) -> None:
9✔
251
        if isinstance(conn, AsyncConnection):
9✔
252
            await conn.run_sync(self._metadata.create_all)
9✔
253
        else:
254
            await to_thread.run_sync(self._metadata.create_all, conn)
5✔
255

256
    async def _execute(
9✔
257
        self,
258
        conn: Connection | AsyncConnection,
259
        statement: Executable,
260
        parameters: Sequence | Mapping | None = None,
261
    ):
262
        if isinstance(conn, AsyncConnection):
9✔
263
            return await conn.execute(statement, parameters)
9✔
264
        else:
265
            return await to_thread.run_sync(conn.execute, statement, parameters)
5✔
266

267
    @property
9✔
268
    def _temporary_failure_exceptions(self) -> tuple[type[Exception], ...]:
9✔
269
        # SQlite does not use the network, so it doesn't have "temporary" failures
270
        if self._engine.dialect.name == "sqlite":
×
271
            return ()
×
272

UNCOV
273
        return InterfaceError, OSError
×
274

275
    def _convert_incoming_fire_times(self, data: dict[str, Any]) -> dict[str, Any]:
9✔
276
        for field in ("last_fire_time", "next_fire_time"):
9✔
277
            if not self._supports_tzaware_timestamps:
9✔
278
                utcoffset_minutes = data.pop(f"{field}_utcoffset", None)
9✔
279
                if utcoffset_minutes is not None:
9✔
280
                    tz = timezone(timedelta(minutes=utcoffset_minutes))
9✔
281
                    timestamp = data[field] / 1000_000
9✔
282
                    data[field] = datetime.fromtimestamp(timestamp, tz=tz)
9✔
283

284
        return data
9✔
285

286
    def _convert_outgoing_fire_times(self, data: dict[str, Any]) -> dict[str, Any]:
9✔
287
        for field in ("last_fire_time", "next_fire_time"):
9✔
288
            if not self._supports_tzaware_timestamps:
9✔
289
                field_value = data[field]
9✔
290
                if field_value is not None:
9✔
291
                    data[field] = int(field_value.timestamp() * 1000_000)
9✔
292
                    data[f"{field}_utcoffset"] = (
9✔
293
                        field_value.utcoffset().total_seconds() // 60
294
                    )
295
                else:
296
                    data[f"{field}_utcoffset"] = None
9✔
297

298
        return data
9✔
299

300
    def get_table_definitions(self) -> MetaData:
9✔
301
        if self._supports_tzaware_timestamps:
9✔
302
            timestamp_type: TypeEngine[datetime] = DateTime(timezone=True)
9✔
303
            last_fire_time_tzoffset_columns: tuple[Column, ...] = (
9✔
304
                Column("last_fire_time", timestamp_type),
305
            )
306
            next_fire_time_tzoffset_columns: tuple[Column, ...] = (
9✔
307
                Column("next_fire_time", timestamp_type, index=True),
308
            )
309
        else:
310
            timestamp_type = EmulatedTimestampTZ()
9✔
311
            last_fire_time_tzoffset_columns = (
9✔
312
                Column("last_fire_time", BigInteger),
313
                Column("last_fire_time_utcoffset", SmallInteger),
314
            )
315
            next_fire_time_tzoffset_columns = (
9✔
316
                Column("next_fire_time", BigInteger, index=True),
317
                Column("next_fire_time_utcoffset", SmallInteger),
318
            )
319

320
        if self._supports_native_interval:
9✔
321
            interval_type: TypeDecorator[timedelta] = Interval(second_precision=6)
9✔
322
        else:
323
            interval_type = EmulatedInterval()
9✔
324

325
        if self._engine.dialect.name == "postgresql":
9✔
326
            from sqlalchemy.dialects.postgresql import JSONB
9✔
327

328
            json_type = JSONB
9✔
329
        else:
330
            json_type = JSON
9✔
331

332
        metadata = MetaData(schema=self.schema)
9✔
333
        Table("metadata", metadata, Column("schema_version", Integer, nullable=False))
9✔
334
        Table(
9✔
335
            "tasks",
336
            metadata,
337
            Column("id", Unicode(500), primary_key=True),
338
            Column("func", Unicode(500)),
339
            Column("job_executor", Unicode(500), nullable=False),
340
            Column("max_running_jobs", Integer),
341
            Column("misfire_grace_time", interval_type),
342
            Column("metadata", json_type, nullable=False),
343
            Column("running_jobs", Integer, nullable=False, server_default=literal(0)),
344
        )
345
        Table(
9✔
346
            "schedules",
347
            metadata,
348
            Column("id", Unicode(500), primary_key=True),
349
            Column("task_id", Unicode(500), nullable=False, index=True),
350
            Column("trigger", LargeBinary),
351
            Column("args", LargeBinary),
352
            Column("kwargs", LargeBinary),
353
            Column("paused", Boolean, nullable=False, server_default=literal(False)),
354
            Column("coalesce", Enum(CoalescePolicy, metadata=metadata), nullable=False),
355
            Column("misfire_grace_time", interval_type),
356
            Column("max_jitter", interval_type),
357
            Column("job_executor", Unicode(500), nullable=False),
358
            Column("job_result_expiration_time", interval_type),
359
            Column("metadata", json_type, nullable=False),
360
            *last_fire_time_tzoffset_columns,
361
            *next_fire_time_tzoffset_columns,
362
            Column("acquired_by", Unicode(500), index=True),
363
            Column("acquired_until", timestamp_type),
364
        )
365
        Table(
9✔
366
            "jobs",
367
            metadata,
368
            Column("id", Uuid, primary_key=True),
369
            Column("task_id", Unicode(500), nullable=False, index=True),
370
            Column("args", LargeBinary, nullable=False),
371
            Column("kwargs", LargeBinary, nullable=False),
372
            Column("schedule_id", Unicode(500), index=True),
373
            Column("scheduled_fire_time", timestamp_type),
374
            Column("executor", Unicode(500), nullable=False),
375
            Column("jitter", interval_type),
376
            Column("start_deadline", timestamp_type),
377
            Column("result_expiration_time", interval_type),
378
            Column("metadata", json_type, nullable=False),
379
            Column("created_at", timestamp_type, nullable=False, index=True),
380
            Column("acquired_by", Unicode(500), index=True),
381
            Column("acquired_until", timestamp_type),
382
        )
383
        Table(
9✔
384
            "job_results",
385
            metadata,
386
            Column("job_id", Uuid, primary_key=True),
387
            Column("outcome", Enum(JobOutcome, metadata=metadata), nullable=False),
388
            Column("started_at", timestamp_type, index=True),
389
            Column("finished_at", timestamp_type, nullable=False),
390
            Column("expires_at", timestamp_type, nullable=False, index=True),
391
            Column("exception", LargeBinary),
392
            Column("return_value", LargeBinary),
393
        )
394
        return metadata
9✔
395

396
    async def start(
9✔
397
        self, exit_stack: AsyncExitStack, event_broker: EventBroker, logger: Logger
398
    ) -> None:
399
        if (asynclib := current_async_library()) != "asyncio":
9✔
UNCOV
400
            raise RuntimeError(
×
401
                f"This data store requires asyncio; currently running: {asynclib}"
402
            )
403

404
        if self._close_on_exit:
9✔
405
            if isinstance(self._engine, AsyncEngine):
×
406
                exit_stack.push_async_callback(self._engine.dispose)
×
407
            else:
408
                exit_stack.callback(self._engine.dispose)
×
409

410
        await super().start(exit_stack, event_broker, logger)
9✔
411

412
        # Verify that the schema is in place
413
        async for attempt in self._retry():
9✔
414
            with attempt:
9✔
415
                async with self._begin_transaction() as conn:
9✔
416
                    # Create the schema first if it doesn't exist yet
417
                    if self.schema:
9✔
418
                        await self._execute(
5✔
419
                            conn, CreateSchema(name=self.schema, if_not_exists=True)
420
                        )
421

422
                    if self.start_from_scratch:
9✔
423
                        for table in self._metadata.sorted_tables:
5✔
424
                            await self._execute(conn, DropTable(table, if_exists=True))
5✔
425

426
                    await self._create_metadata(conn)
9✔
427
                    query = select(self._t_metadata.c.schema_version)
9✔
428
                    result = await self._execute(conn, query)
9✔
429
                    version = result.scalar()
9✔
430
                    if version is None:
9✔
431
                        await self._execute(
9✔
432
                            conn, self._t_metadata.insert(), {"schema_version": 1}
433
                        )
434
                    elif version > 1:
9✔
435
                        raise RuntimeError(
4✔
436
                            f"Unexpected schema version ({version}); "
437
                            f"only version 1 is supported by this version of "
438
                            f"APScheduler"
439
                        )
440

441
        # Find out if the dialect supports UPDATE...RETURNING
442
        async for attempt in self._retry():
9✔
443
            with attempt:
9✔
444
                update = (
9✔
445
                    self._t_metadata.update()
446
                    .values(schema_version=self._t_metadata.c.schema_version)
447
                    .returning(self._t_metadata.c.schema_version)
448
                )
449
                async with self._begin_transaction() as conn:
9✔
450
                    try:
9✔
451
                        await self._execute(conn, update)
9✔
452
                    except (CompileError, ProgrammingError):
5✔
453
                        pass  # the support flag is False by default
5✔
454
                    else:
455
                        self._supports_update_returning = True
9✔
456

457
    async def _deserialize_schedules(self, result: Result) -> list[Schedule]:
9✔
458
        schedules: list[Schedule] = []
9✔
459
        for row in result:
9✔
460
            try:
9✔
461
                schedules.append(
9✔
462
                    Schedule.unmarshal(
463
                        self.serializer,
464
                        self._convert_incoming_fire_times(row._asdict()),
465
                    )
466
                )
467
            except SerializationError as exc:
×
468
                await self._event_broker.publish(
×
469
                    ScheduleDeserializationFailed(schedule_id=row.id, exception=exc)
470
                )
471

472
        return schedules
9✔
473

474
    async def _deserialize_jobs(self, result: Result) -> list[Job]:
9✔
475
        jobs: list[Job] = []
9✔
476
        for row in result:
9✔
477
            try:
9✔
478
                jobs.append(Job.unmarshal(self.serializer, row._asdict()))
9✔
479
            except SerializationError as exc:
×
480
                await self._event_broker.publish(
×
481
                    JobDeserializationFailed(job_id=row.id, exception=exc)
482
                )
483

484
        return jobs
9✔
485

486
    async def add_task(self, task: Task) -> None:
9✔
487
        insert = self._t_tasks.insert().values(
9✔
488
            id=task.id,
489
            func=task.func,
490
            job_executor=task.job_executor,
491
            max_running_jobs=task.max_running_jobs,
492
            misfire_grace_time=task.misfire_grace_time,
493
            metadata=task.metadata,
494
        )
495
        try:
9✔
496
            async for attempt in self._retry():
9✔
497
                with attempt:
9✔
498
                    async with self._begin_transaction() as conn:
9✔
499
                        await self._execute(conn, insert)
9✔
500
        except IntegrityError:
9✔
501
            update = (
9✔
502
                self._t_tasks.update()
503
                .values(
504
                    func=task.func,
505
                    job_executor=task.job_executor,
506
                    max_running_jobs=task.max_running_jobs,
507
                    misfire_grace_time=task.misfire_grace_time,
508
                    metadata=task.metadata,
509
                )
510
                .where(self._t_tasks.c.id == task.id)
511
            )
512
            async for attempt in self._retry():
9✔
513
                with attempt:
9✔
514
                    async with self._begin_transaction() as conn:
9✔
515
                        await self._execute(conn, update)
9✔
516

517
            await self._event_broker.publish(TaskUpdated(task_id=task.id))
9✔
518
        else:
519
            await self._event_broker.publish(TaskAdded(task_id=task.id))
9✔
520

521
    async def remove_task(self, task_id: str) -> None:
9✔
522
        delete = self._t_tasks.delete().where(self._t_tasks.c.id == task_id)
×
523
        async for attempt in self._retry():
×
524
            with attempt:
×
525
                async with self._begin_transaction() as conn:
×
526
                    result = await self._execute(conn, delete)
×
527
                    if result.rowcount == 0:
×
528
                        raise TaskLookupError(task_id)
×
529
                    else:
UNCOV
530
                        await self._event_broker.publish(TaskRemoved(task_id=task_id))
×
531

532
    async def get_task(self, task_id: str) -> Task:
9✔
533
        query = self._t_tasks.select().where(self._t_tasks.c.id == task_id)
9✔
534
        async for attempt in self._retry():
9✔
535
            with attempt:
9✔
536
                async with self._begin_transaction() as conn:
9✔
537
                    result = await self._execute(conn, query)
9✔
538
                    row = result.first()
9✔
539

540
        if row:
9✔
541
            return Task.unmarshal(self.serializer, row._asdict())
9✔
542
        else:
543
            raise TaskLookupError(task_id)
9✔
544

545
    async def get_tasks(self) -> list[Task]:
9✔
546
        query = self._t_tasks.select().order_by(self._t_tasks.c.id)
9✔
547
        async for attempt in self._retry():
9✔
548
            with attempt:
9✔
549
                async with self._begin_transaction() as conn:
9✔
550
                    result = await self._execute(conn, query)
9✔
551
                    tasks = [
9✔
552
                        Task.unmarshal(self.serializer, row._asdict()) for row in result
553
                    ]
554

555
        return tasks
9✔
556

557
    async def add_schedule(
9✔
558
        self, schedule: Schedule, conflict_policy: ConflictPolicy
559
    ) -> None:
560
        event: DataStoreEvent
561
        values = self._convert_outgoing_fire_times(schedule.marshal(self.serializer))
9✔
562
        insert = self._t_schedules.insert().values(**values)
9✔
563
        try:
9✔
564
            async for attempt in self._retry():
9✔
565
                with attempt:
9✔
566
                    async with self._begin_transaction() as conn:
9✔
567
                        await self._execute(conn, insert)
9✔
568
        except IntegrityError:
9✔
569
            if conflict_policy is ConflictPolicy.exception:
9✔
570
                raise ConflictingIdError(schedule.id) from None
×
571
            elif conflict_policy is ConflictPolicy.replace:
9✔
572
                del values["id"]
9✔
573
                update = (
9✔
574
                    self._t_schedules.update()
575
                    .where(self._t_schedules.c.id == schedule.id)
576
                    .values(**values)
577
                )
578
                async for attempt in self._retry():
9✔
579
                    with attempt:
9✔
580
                        async with self._begin_transaction() as conn:
9✔
581
                            await self._execute(conn, update)
9✔
582

583
                event = ScheduleUpdated(
9✔
584
                    schedule_id=schedule.id,
585
                    task_id=schedule.task_id,
586
                    next_fire_time=schedule.next_fire_time,
587
                )
588
                await self._event_broker.publish(event)
9✔
589
        else:
590
            event = ScheduleAdded(
9✔
591
                schedule_id=schedule.id,
592
                task_id=schedule.task_id,
593
                next_fire_time=schedule.next_fire_time,
594
            )
595
            await self._event_broker.publish(event)
9✔
596

597
    async def remove_schedules(self, ids: Iterable[str]) -> None:
9✔
598
        async for attempt in self._retry():
9✔
599
            with attempt:
9✔
600
                async with self._begin_transaction() as conn:
9✔
601
                    if self._supports_update_returning:
9✔
602
                        delete_returning = (
9✔
603
                            self._t_schedules.delete()
604
                            .where(self._t_schedules.c.id.in_(ids))
605
                            .returning(
606
                                self._t_schedules.c.id, self._t_schedules.c.task_id
607
                            )
608
                        )
609
                        removed_ids: list[tuple[str, str]] = [
9✔
610
                            (row[0], row[1])
611
                            for row in await self._execute(conn, delete_returning)
612
                        ]
613
                    else:
614
                        query = select(
5✔
615
                            self._t_schedules.c.id, self._t_schedules.c.task_id
616
                        ).where(self._t_schedules.c.id.in_(ids))
617
                        ids_to_remove: list[str] = []
5✔
618
                        removed_ids = []
5✔
619
                        for schedule_id, task_id in await self._execute(conn, query):
5✔
620
                            ids_to_remove.append(schedule_id)
5✔
621
                            removed_ids.append((schedule_id, task_id))
5✔
622

623
                        delete = self._t_schedules.delete().where(
5✔
624
                            self._t_schedules.c.id.in_(ids_to_remove)
625
                        )
626
                        await self._execute(conn, delete)
5✔
627

628
        for schedule_id, task_id in removed_ids:
9✔
629
            await self._event_broker.publish(
9✔
630
                ScheduleRemoved(
631
                    schedule_id=schedule_id, task_id=task_id, finished=False
632
                )
633
            )
634

635
    async def get_schedules(self, ids: set[str] | None = None) -> list[Schedule]:
9✔
636
        query = self._t_schedules.select().order_by(self._t_schedules.c.id)
9✔
637
        if ids:
9✔
638
            query = query.where(self._t_schedules.c.id.in_(ids))
9✔
639

640
        async for attempt in self._retry():
9✔
641
            with attempt:
9✔
642
                async with self._begin_transaction() as conn:
9✔
643
                    result = await self._execute(conn, query)
9✔
644

645
        return await self._deserialize_schedules(result)
9✔
646

647
    async def acquire_schedules(
9✔
648
        self, scheduler_id: str, lease_duration: timedelta, limit: int
649
    ) -> list[Schedule]:
650
        async for attempt in self._retry():
9✔
651
            with attempt:
9✔
652
                async with self._begin_transaction() as conn:
9✔
653
                    now = datetime.now(timezone.utc)
9✔
654
                    acquired_until = now + lease_duration
9✔
655
                    if self._supports_tzaware_timestamps:
9✔
656
                        comparison = self._t_schedules.c.next_fire_time <= now
5✔
657
                    else:
658
                        comparison = self._t_schedules.c.next_fire_time <= int(
9✔
659
                            now.timestamp() * 1000_000
660
                        )
661

662
                    schedules_cte = (
9✔
663
                        select(self._t_schedules.c.id)
664
                        .where(
665
                            and_(
666
                                self._t_schedules.c.next_fire_time.isnot(None),
667
                                comparison,
668
                                self._t_schedules.c.paused == false(),
669
                                or_(
670
                                    self._t_schedules.c.acquired_by == scheduler_id,
671
                                    self._t_schedules.c.acquired_until.is_(None),
672
                                    self._t_schedules.c.acquired_until < now,
673
                                ),
674
                            )
675
                        )
676
                        .order_by(self._t_schedules.c.next_fire_time)
677
                        .limit(limit)
678
                        .with_for_update(skip_locked=True)
679
                        .cte()
680
                    )
681
                    subselect = select(schedules_cte.c.id)
9✔
682
                    update = (
9✔
683
                        self._t_schedules.update()
684
                        .where(self._t_schedules.c.id.in_(subselect))
685
                        .values(acquired_by=scheduler_id, acquired_until=acquired_until)
686
                    )
687
                    if self._supports_update_returning:
9✔
688
                        update = update.returning(*self._t_schedules.columns)
9✔
689
                        result = await self._execute(conn, update)
9✔
690
                    else:
691
                        await self._execute(conn, update)
5✔
692
                        query = self._t_schedules.select().where(
5✔
693
                            and_(self._t_schedules.c.acquired_by == scheduler_id)
694
                        )
695
                        result = await self._execute(conn, query)
5✔
696

697
                    schedules = await self._deserialize_schedules(result)
9✔
698

699
        return schedules
9✔
700

701
    async def release_schedules(
9✔
702
        self, scheduler_id: str, results: Sequence[ScheduleResult]
703
    ) -> None:
704
        task_ids = {result.schedule_id: result.task_id for result in results}
9✔
705
        next_fire_times = {
9✔
706
            result.schedule_id: result.next_fire_time for result in results
707
        }
708
        async for attempt in self._retry():
9✔
709
            with attempt:
9✔
710
                async with self._begin_transaction() as conn:
9✔
711
                    update_events: list[ScheduleUpdated] = []
9✔
712
                    finished_schedule_ids: list[str] = []
9✔
713
                    update_args: list[dict[str, Any]] = []
9✔
714
                    for result in results:
9✔
715
                        try:
9✔
716
                            serialized_trigger = self.serializer.serialize(
9✔
717
                                result.trigger
718
                            )
719
                        except SerializationError:
×
720
                            self._logger.exception(
2✔
721
                                "Error serializing trigger for schedule %r – "
722
                                "removing from data store",
723
                                result.schedule_id,
724
                            )
725
                            finished_schedule_ids.append(result.schedule_id)
2✔
726
                            continue
2✔
727

728
                        if self._supports_tzaware_timestamps:
9✔
729
                            update_args.append(
6✔
730
                                {
731
                                    "p_id": result.schedule_id,
732
                                    "p_trigger": serialized_trigger,
733
                                    "p_last_fire_time": result.last_fire_time,
734
                                    "p_next_fire_time": result.next_fire_time,
735
                                }
736
                            )
737
                        else:
738
                            update_args.append(
9✔
739
                                {
740
                                    "p_id": result.schedule_id,
741
                                    "p_trigger": serialized_trigger,
742
                                    **marshal_timestamp(
743
                                        result.last_fire_time, "p_last_fire_time"
744
                                    ),
745
                                    **marshal_timestamp(
746
                                        result.next_fire_time, "p_next_fire_time"
747
                                    ),
748
                                }
749
                            )
750

751
                    # Update schedules
752
                    if update_args:
9✔
753
                        extra_values: dict[str, BindParameter] = {}
9✔
754
                        p_id: BindParameter = bindparam("p_id")
9✔
755
                        p_trigger: BindParameter = bindparam("p_trigger")
9✔
756
                        p_last_fire_time: BindParameter = bindparam("p_last_fire_time")
9✔
757
                        p_next_fire_time: BindParameter = bindparam("p_next_fire_time")
9✔
758
                        if not self._supports_tzaware_timestamps:
9✔
759
                            extra_values["last_fire_time_utcoffset"] = bindparam(
9✔
760
                                "p_last_fire_time_utcoffset"
761
                            )
762
                            extra_values["next_fire_time_utcoffset"] = bindparam(
9✔
763
                                "p_next_fire_time_utcoffset"
764
                            )
765

766
                        update = (
9✔
767
                            self._t_schedules.update()
768
                            .where(
769
                                and_(
770
                                    self._t_schedules.c.id == p_id,
771
                                    self._t_schedules.c.acquired_by == scheduler_id,
772
                                )
773
                            )
774
                            .values(
775
                                trigger=p_trigger,
776
                                last_fire_time=p_last_fire_time,
777
                                next_fire_time=p_next_fire_time,
778
                                acquired_by=None,
779
                                acquired_until=None,
780
                                **extra_values,
781
                            )
782
                        )
783
                        # TODO: actually check which rows were updated?
784
                        await self._execute(conn, update, update_args)
9✔
785
                        updated_ids = list(next_fire_times)
9✔
786

787
                        for schedule_id in updated_ids:
9✔
788
                            event = ScheduleUpdated(
9✔
789
                                schedule_id=schedule_id,
790
                                task_id=task_ids[schedule_id],
791
                                next_fire_time=next_fire_times[schedule_id],
792
                            )
793
                            update_events.append(event)
9✔
794

795
                    # Remove schedules that failed to serialize
796
                    if finished_schedule_ids:
9✔
797
                        delete = self._t_schedules.delete().where(
×
798
                            self._t_schedules.c.id.in_(finished_schedule_ids)
799
                        )
800
                        await self._execute(conn, delete)
×
801

802
        for event in update_events:
9✔
803
            await self._event_broker.publish(event)
9✔
804

805
        for schedule_id in finished_schedule_ids:
9✔
806
            await self._event_broker.publish(
1✔
807
                ScheduleRemoved(
808
                    schedule_id=schedule_id,
809
                    task_id=task_ids[schedule_id],
810
                    finished=True,
811
                )
812
            )
813

814
    async def get_next_schedule_run_time(self) -> datetime | None:
9✔
815
        columns = [self._t_schedules.c.next_fire_time]
9✔
816
        if not self._supports_tzaware_timestamps:
9✔
817
            columns.append(self._t_schedules.c.next_fire_time_utcoffset)
9✔
818

819
        statenent = (
9✔
820
            select(*columns)
821
            .where(
822
                self._t_schedules.c.next_fire_time.isnot(None),
823
                self._t_schedules.c.paused == false(),
824
                self._t_schedules.c.acquired_by.is_(None),
825
            )
826
            .order_by(self._t_schedules.c.next_fire_time)
827
            .limit(1)
828
        )
829
        async for attempt in self._retry():
9✔
830
            with attempt:
9✔
831
                async with self._begin_transaction() as conn:
9✔
832
                    result = await self._execute(conn, statenent)
9✔
833

834
        if not self._supports_tzaware_timestamps:
9✔
835
            if row := result.first():
9✔
836
                tz = timezone(timedelta(minutes=row[1]))
9✔
837
                return datetime.fromtimestamp(row[0] / 1000_000, tz=tz)
9✔
838
            else:
839
                return None
9✔
840

841
        return result.scalar()
5✔
842

843
    async def add_job(self, job: Job) -> None:
9✔
844
        marshalled = job.marshal(self.serializer)
9✔
845
        insert = self._t_jobs.insert().values(**marshalled)
9✔
846
        async for attempt in self._retry():
9✔
847
            with attempt:
9✔
848
                async with self._begin_transaction() as conn:
9✔
849
                    await self._execute(conn, insert)
9✔
850

851
        event = JobAdded(
9✔
852
            job_id=job.id,
853
            task_id=job.task_id,
854
            schedule_id=job.schedule_id,
855
        )
856
        await self._event_broker.publish(event)
9✔
857

858
    async def get_jobs(self, ids: Iterable[UUID] | None = None) -> list[Job]:
9✔
859
        query = self._t_jobs.select().order_by(self._t_jobs.c.id)
9✔
860
        if ids:
9✔
861
            job_ids = list(ids)
9✔
862
            query = query.where(self._t_jobs.c.id.in_(job_ids))
9✔
863

864
        async for attempt in self._retry():
9✔
865
            with attempt:
9✔
866
                async with self._begin_transaction() as conn:
9✔
867
                    result = await self._execute(conn, query)
9✔
868

869
        return await self._deserialize_jobs(result)
9✔
870

871
    async def acquire_jobs(
9✔
872
        self, scheduler_id: str, lease_duration: timedelta, limit: int | None = None
873
    ) -> list[Job]:
874
        events: list[JobAcquired | JobReleased] = []
9✔
875
        async for attempt in self._retry():
9✔
876
            with attempt:
9✔
877
                async with self._begin_transaction() as conn:
9✔
878
                    now = datetime.now(timezone.utc)
9✔
879
                    acquired_until = now + lease_duration
9✔
880
                    query = (
9✔
881
                        select(
882
                            self._t_jobs,
883
                            self._t_tasks.c.max_running_jobs,
884
                            self._t_tasks.c.running_jobs,
885
                        )
886
                        .join(
887
                            self._t_tasks, self._t_tasks.c.id == self._t_jobs.c.task_id
888
                        )
889
                        .where(
890
                            or_(
891
                                self._t_jobs.c.acquired_until.is_(None),
892
                                self._t_jobs.c.acquired_until < now,
893
                            )
894
                        )
895
                        .order_by(self._t_jobs.c.created_at)
896
                        .with_for_update(
897
                            skip_locked=True,
898
                            of=[
899
                                self._t_tasks.c.running_jobs,
900
                                self._t_jobs.c.acquired_by,
901
                                self._t_jobs.c.acquired_until,
902
                            ],
903
                        )
904
                        .limit(limit)
905
                    )
906

907
                    result = await self._execute(conn, query)
9✔
908
                    if not result:
9✔
909
                        return []
×
910

911
                    acquired_jobs: list[Job] = []
9✔
912
                    discarded_jobs: list[_JobDiscard] = []
9✔
913
                    task_job_slots_left: dict[str, float] = defaultdict(
9✔
914
                        lambda: float("inf")
915
                    )
916
                    running_job_count_increments: dict[str, int] = defaultdict(
9✔
917
                        lambda: 0
918
                    )
919
                    for row in result:
9✔
920
                        job_dict = row._asdict()
9✔
921
                        task_max_running_jobs = job_dict.pop("max_running_jobs")
9✔
922
                        task_running_jobs = job_dict.pop("running_jobs")
9✔
923
                        if task_max_running_jobs is not None:
9✔
924
                            task_job_slots_left.setdefault(
9✔
925
                                row.task_id, task_max_running_jobs - task_running_jobs
926
                            )
927

928
                        # Deserialize the job
929
                        try:
9✔
930
                            job = Job.unmarshal(self.serializer, job_dict)
9✔
931
                        except DeserializationError as exc:
9✔
932
                            # Deserialization failed, so record the exception as the job
933
                            # result
934
                            discarded_jobs.append(
9✔
935
                                _JobDiscard(
936
                                    job_id=row.id,
937
                                    outcome=JobOutcome.deserialization_failed,
938
                                    task_id=row.task_id,
939
                                    schedule_id=row.schedule_id,
940
                                    scheduled_fire_time=row.scheduled_fire_time,
941
                                    result_expires_at=now + row.result_expiration_time,
942
                                    exception=exc,
943
                                )
944
                            )
945
                            continue
9✔
946

947
                        # Discard the job if its start deadline has passed
948
                        if job.start_deadline and job.start_deadline < now:
9✔
949
                            discarded_jobs.append(
9✔
950
                                _JobDiscard(
951
                                    job_id=row.id,
952
                                    outcome=JobOutcome.missed_start_deadline,
953
                                    task_id=row.task_id,
954
                                    schedule_id=row.schedule_id,
955
                                    scheduled_fire_time=row.scheduled_fire_time,
956
                                    result_expires_at=now + row.result_expiration_time,
957
                                )
958
                            )
959
                            continue
9✔
960

961
                        # Skip the job if no more slots are available
962
                        if not task_job_slots_left[job.task_id]:
9✔
963
                            self._logger.debug(
9✔
964
                                "Skipping job %s because task %r has the maximum "
965
                                "number of %d jobs already running",
966
                                job.id,
967
                                job.task_id,
968
                                task_max_running_jobs,
969
                            )
970
                            continue
9✔
971

972
                        task_job_slots_left[job.task_id] -= 1
9✔
973
                        running_job_count_increments[job.task_id] += 1
9✔
974
                        job.acquired_by = scheduler_id
9✔
975
                        job.acquired_until = acquired_until
9✔
976
                        acquired_jobs.append(job)
9✔
977
                        events.append(
9✔
978
                            JobAcquired.from_job(job, scheduler_id=scheduler_id)
979
                        )
980

981
                    if acquired_jobs:
9✔
982
                        # Mark the acquired jobs as acquired by this worker
983
                        acquired_job_ids = [job.id for job in acquired_jobs]
9✔
984
                        update = (
9✔
985
                            self._t_jobs.update()
986
                            .values(
987
                                acquired_by=scheduler_id, acquired_until=acquired_until
988
                            )
989
                            .where(self._t_jobs.c.id.in_(acquired_job_ids))
990
                        )
991
                        await self._execute(conn, update)
9✔
992

993
                        # Increment the running job counters on each task
994
                        p_id: BindParameter = bindparam("p_id")
9✔
995
                        p_increment: BindParameter = bindparam("p_increment")
9✔
996
                        params = [
9✔
997
                            {"p_id": task_id, "p_increment": increment}
998
                            for task_id, increment in running_job_count_increments.items()
999
                        ]
1000
                        update = (
9✔
1001
                            self._t_tasks.update()
1002
                            .values(
1003
                                running_jobs=self._t_tasks.c.running_jobs + p_increment
1004
                            )
1005
                            .where(self._t_tasks.c.id == p_id)
1006
                        )
1007
                        await self._execute(conn, update, params)
9✔
1008

1009
                    # Discard the jobs that could not start
1010
                    for discard in discarded_jobs:
9✔
1011
                        result = JobResult(
9✔
1012
                            job_id=discard.job_id,
1013
                            outcome=discard.outcome,
1014
                            finished_at=now,
1015
                            expires_at=discard.result_expires_at,
1016
                            exception=discard.exception,
1017
                        )
1018
                        events.append(
9✔
1019
                            await self._release_job(
1020
                                conn,
1021
                                result,
1022
                                scheduler_id,
1023
                                discard.task_id,
1024
                                discard.schedule_id,
1025
                                discard.scheduled_fire_time,
1026
                                decrement_running_job_count=False,
1027
                            )
1028
                        )
1029

1030
        # Publish the appropriate events
1031
        for event in events:
9✔
1032
            await self._event_broker.publish(event)
9✔
1033

1034
        return acquired_jobs
9✔
1035

1036
    async def _release_job(
9✔
1037
        self,
1038
        conn: Connection | AsyncConnection,
1039
        result: JobResult,
1040
        scheduler_id: str,
1041
        task_id: str,
1042
        schedule_id: str | None = None,
1043
        scheduled_fire_time: datetime | None = None,
1044
        *,
1045
        decrement_running_job_count: bool = True,
1046
    ) -> JobReleased:
1047
        # Record the job result
1048
        if result.expires_at > result.finished_at:
9✔
1049
            marshalled = result.marshal(self.serializer)
9✔
1050
            insert = self._t_job_results.insert().values(**marshalled)
9✔
1051
            await self._execute(conn, insert)
9✔
1052

1053
        # Decrement the number of running jobs for this task
1054
        if decrement_running_job_count:
9✔
1055
            update = (
9✔
1056
                self._t_tasks.update()
1057
                .values(running_jobs=self._t_tasks.c.running_jobs - 1)
1058
                .where(self._t_tasks.c.id == task_id)
1059
            )
1060
            await self._execute(conn, update)
9✔
1061

1062
        # Delete the job
1063
        delete = self._t_jobs.delete().where(self._t_jobs.c.id == result.job_id)
9✔
1064
        await self._execute(conn, delete)
9✔
1065

1066
        # Create the event, to be sent after commit
1067
        return JobReleased.from_result(
9✔
1068
            result, scheduler_id, task_id, schedule_id, scheduled_fire_time
1069
        )
1070

1071
    async def release_job(self, scheduler_id: str, job: Job, result: JobResult) -> None:
9✔
1072
        async for attempt in self._retry():
9✔
1073
            with attempt:
9✔
1074
                async with self._begin_transaction() as conn:
9✔
1075
                    event = await self._release_job(
9✔
1076
                        conn,
1077
                        result,
1078
                        scheduler_id,
1079
                        job.task_id,
1080
                        job.schedule_id,
1081
                        job.scheduled_fire_time,
1082
                    )
1083

1084
        # Notify other schedulers
1085
        await self._event_broker.publish(event)
9✔
1086

1087
    async def get_job_result(self, job_id: UUID) -> JobResult | None:
9✔
1088
        async for attempt in self._retry():
9✔
1089
            with attempt:
9✔
1090
                async with self._begin_transaction() as conn:
9✔
1091
                    # Retrieve the result
1092
                    query = self._t_job_results.select().where(
9✔
1093
                        self._t_job_results.c.job_id == job_id
1094
                    )
1095
                    if row := (await self._execute(conn, query)).one_or_none():
9✔
1096
                        # Delete the result
1097
                        delete = self._t_job_results.delete().where(
9✔
1098
                            self._t_job_results.c.job_id == job_id
1099
                        )
1100
                        await self._execute(conn, delete)
9✔
1101

1102
        return JobResult.unmarshal(self.serializer, row._asdict()) if row else None
9✔
1103

1104
    async def extend_acquired_schedule_leases(
9✔
1105
        self, scheduler_id: str, schedule_ids: set[str], duration: timedelta
1106
    ) -> None:
1107
        async for attempt in self._retry():
9✔
1108
            with attempt:
9✔
1109
                async with self._begin_transaction() as conn:
9✔
1110
                    new_acquired_until = datetime.now(timezone.utc) + duration
9✔
1111
                    update = (
9✔
1112
                        self._t_schedules.update()
1113
                        .values(acquired_until=new_acquired_until)
1114
                        .where(
1115
                            self._t_schedules.c.acquired_by == scheduler_id,
1116
                            self._t_schedules.c.id.in_(schedule_ids),
1117
                        )
1118
                    )
1119
                    await self._execute(conn, update)
9✔
1120

1121
    async def extend_acquired_job_leases(
9✔
1122
        self, scheduler_id: str, job_ids: set[UUID], duration: timedelta
1123
    ) -> None:
1124
        async for attempt in self._retry():
9✔
1125
            with attempt:
9✔
1126
                async with self._begin_transaction() as conn:
9✔
1127
                    new_acquired_until = datetime.now(timezone.utc) + duration
9✔
1128
                    update = (
9✔
1129
                        self._t_jobs.update()
1130
                        .values(acquired_until=new_acquired_until)
1131
                        .where(
1132
                            self._t_jobs.c.acquired_by == scheduler_id,
1133
                            self._t_jobs.c.id.in_(job_ids),
1134
                        )
1135
                    )
1136
                    await self._execute(conn, update)
9✔
1137

1138
    async def reap_abandoned_jobs(self, scheduler_id: str) -> None:
9✔
1139
        query = (
9✔
1140
            select(self._t_jobs)
1141
            .where(self._t_jobs.c.acquired_by == scheduler_id)
1142
            .with_for_update()
1143
        )
1144
        async for attempt in self._retry():
9✔
1145
            events: list[JobReleased] = []
9✔
1146
            with attempt:
9✔
1147
                async with self._begin_transaction() as conn:
9✔
1148
                    if results := await self._execute(conn, query):
9✔
1149
                        for row in results:
9✔
1150
                            job_dict = self._convert_incoming_fire_times(row._asdict())
9✔
1151
                            job = Job.unmarshal(
9✔
1152
                                self.serializer, {**job_dict, "args": (), "kwargs": {}}
1153
                            )
1154
                            result = JobResult.from_job(job, JobOutcome.abandoned)
9✔
1155
                            event = await self._release_job(
9✔
1156
                                conn,
1157
                                result,
1158
                                scheduler_id,
1159
                                job.task_id,
1160
                                job.schedule_id,
1161
                                job.scheduled_fire_time,
1162
                            )
1163
                            events.append(event)
9✔
1164

1165
            for event in events:
9✔
1166
                await self._event_broker.publish(event)
9✔
1167

1168
    async def cleanup(self) -> None:
9✔
1169
        async for attempt in self._retry():
9✔
1170
            with attempt:
9✔
1171
                events: list[Event] = []
9✔
1172
                async with self._begin_transaction() as conn:
9✔
1173
                    # Purge expired job results
1174
                    delete = self._t_job_results.delete().where(
9✔
1175
                        self._t_job_results.c.expires_at <= datetime.now(timezone.utc)
1176
                    )
1177
                    await self._execute(conn, delete)
9✔
1178

1179
                    # Finish any jobs whose leases have expired
1180
                    now = datetime.now(timezone.utc)
9✔
1181
                    query = select(
9✔
1182
                        self._t_jobs.c.id,
1183
                        self._t_jobs.c.task_id,
1184
                        self._t_jobs.c.schedule_id,
1185
                        self._t_jobs.c.scheduled_fire_time,
1186
                        self._t_jobs.c.acquired_by,
1187
                        self._t_jobs.c.result_expiration_time,
1188
                    ).where(
1189
                        self._t_jobs.c.acquired_by.isnot(None),
1190
                        self._t_jobs.c.acquired_until < now,
1191
                    )
1192
                    for row in await self._execute(conn, query):
9✔
1193
                        result = JobResult(
9✔
1194
                            job_id=row.id,
1195
                            outcome=JobOutcome.abandoned,
1196
                            finished_at=now,
1197
                            expires_at=now + row.result_expiration_time,
1198
                        )
1199
                        events.append(
9✔
1200
                            await self._release_job(
1201
                                conn,
1202
                                result,
1203
                                row.acquired_by,
1204
                                row.task_id,
1205
                                row.schedule_id,
1206
                                row.scheduled_fire_time,
1207
                            )
1208
                        )
1209

1210
                    # Clean up finished schedules that have no running jobs
1211
                    query = (
9✔
1212
                        select(self._t_schedules.c.id, self._t_schedules.c.task_id)
1213
                        .outerjoin(
1214
                            self._t_jobs,
1215
                            self._t_jobs.c.schedule_id == self._t_schedules.c.id,
1216
                        )
1217
                        .where(
1218
                            self._t_schedules.c.next_fire_time.is_(None),
1219
                            self._t_jobs.c.id.is_(None),
1220
                        )
1221
                    )
1222
                    results = await self._execute(conn, query)
9✔
1223
                    if finished_schedule_ids := dict(results.all()):
9✔
1224
                        delete = self._t_schedules.delete().where(
9✔
1225
                            self._t_schedules.c.id.in_(finished_schedule_ids)
1226
                        )
1227
                        await self._execute(conn, delete)
9✔
1228

1229
                    for schedule_id, task_id in finished_schedule_ids.items():
9✔
1230
                        events.append(
9✔
1231
                            ScheduleRemoved(
1232
                                schedule_id=schedule_id,
1233
                                task_id=task_id,
1234
                                finished=True,
1235
                            )
1236
                        )
1237

1238
                # Publish any events produced from the operations
1239
                for event in events:
9✔
1240
                    await self._event_broker.publish(event)
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