schedex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
schedex/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ from . import mem
2
+ from .context import Context, TaskInfo
3
+ from .eventbus import BufferedEventSender, Event, EventKind, EventReceiver, EventSender
4
+ from .executor import (
5
+ ExecutorMiddleware,
6
+ ExecutorMiddlewareWrappedFunc,
7
+ TaskFetcher,
8
+ Worker,
9
+ WorkerPool,
10
+ WorkerPoolExecutor,
11
+ )
12
+ from .lock import EventManagerMixin, JobLock, Lock, LockManager, TaskLock
13
+ from .schedule import CombinedSchedule, PeriodicSchedule, Schedule
14
+ from .scheduler import (
15
+ Job,
16
+ OwnedSchedulerTransaction,
17
+ Scheduler,
18
+ SchedulerMiddleware,
19
+ SchedulerMiddlewareWrappedFunc,
20
+ SchedulerTransaction,
21
+ TransactionalScheduler,
22
+ )
23
+ from .serializer import Serializable
24
+ from .storage import (
25
+ JobManager,
26
+ JobStatus,
27
+ OwnedTransaction,
28
+ StoredJob,
29
+ StoredTask,
30
+ TaskManager,
31
+ TaskStatus,
32
+ Transaction,
33
+ TransactionManager,
34
+ )
35
+ from .task import BoundTask, Task, TaskRegistry
schedex/context.py ADDED
@@ -0,0 +1,40 @@
1
+ import dataclasses as dc
2
+ import datetime as dt
3
+
4
+ from .metadata import Metadata
5
+
6
+
7
+ @dc.dataclass(frozen=True)
8
+ class TaskInfo:
9
+ """
10
+ Executing task information.
11
+
12
+ :param id: task identifier
13
+ :param job_id: job identifier which spawned the task
14
+ :param sequence_number: task sequence number (unique within a job)
15
+ :param created_at: task creation time
16
+ :param attempts: number of task execution attempts
17
+ :param meta: task metadata
18
+ """
19
+
20
+ id: str
21
+ job_id: str
22
+ sequence_number: int
23
+ created_at: dt.datetime
24
+ attempts: int
25
+ meta: Metadata
26
+
27
+
28
+ @dc.dataclass(frozen=True)
29
+ class Context[StT, LkT]:
30
+ """
31
+ Task context.
32
+
33
+ :param state: application state
34
+ :param task: executing task
35
+ :param lock: executing task lock
36
+ """
37
+
38
+ state: StT
39
+ task: TaskInfo
40
+ lock: LkT
File without changes
@@ -0,0 +1,53 @@
1
+ from collections import ChainMap
2
+ from typing import Any, Callable, Concatenate, MutableMapping, Never
3
+
4
+ import dishka
5
+ from dishka.integrations.base import wrap_injection
6
+
7
+ from schedex import Context, ExecutorMiddlewareWrappedFunc, Task
8
+
9
+ CONTAINER_KEY = "dishka_container"
10
+
11
+ AppState = MutableMapping[str, Any]
12
+
13
+
14
+ class InjectedType:
15
+ def __getattribute__(self, item: Any) -> Never:
16
+ raise AssertionError("argument is not injected")
17
+
18
+
19
+ Injected: Any = InjectedType()
20
+
21
+
22
+ def inject[StT: AppState, **P, R](
23
+ func: Callable[Concatenate[Context[StT, Any], P], R],
24
+ ) -> Callable[Concatenate[Context[StT, Any], P], R]:
25
+ """
26
+ Function decorator injecting dependencies into the function arguments.
27
+ """
28
+
29
+ return wrap_injection(
30
+ func=func,
31
+ is_async=True,
32
+ container_getter=lambda args, kwargs: args[0].state[CONTAINER_KEY],
33
+ )
34
+
35
+
36
+ async def request_container_middleware(
37
+ context: Context[AppState, Any],
38
+ task: Task[Context[AppState, Any], Any, Any],
39
+ /,
40
+ *,
41
+ wrapped: ExecutorMiddlewareWrappedFunc[Context[AppState, Any]],
42
+ ) -> None:
43
+ """
44
+ A middleware that initialize request-scoped di container.
45
+ Works only with MutableMapping application state.
46
+ """
47
+
48
+ container: dishka.AsyncContainer = context.state[CONTAINER_KEY]
49
+
50
+ async with container(scope=dishka.Scope.REQUEST) as request_container:
51
+ request_context = ChainMap({CONTAINER_KEY: request_container}, context.state)
52
+ context = Context(state=request_context, task=context.task, lock=context.lock)
53
+ return await wrapped(context, task)
@@ -0,0 +1,213 @@
1
+ import timeit
2
+ from typing import Any, Optional
3
+
4
+ import opentelemetry as otl
5
+ import opentelemetry.baggage
6
+ import opentelemetry.metrics
7
+ import opentelemetry.propagate
8
+ import opentelemetry.propagators.textmap
9
+ import opentelemetry.trace.propagation.tracecontext
10
+ from opentelemetry.util import types
11
+
12
+ from schedex import (
13
+ Context,
14
+ ExecutorMiddleware,
15
+ ExecutorMiddlewareWrappedFunc,
16
+ Job,
17
+ Schedule,
18
+ SchedulerMiddleware,
19
+ SchedulerMiddlewareWrappedFunc,
20
+ Task,
21
+ )
22
+
23
+ INSTRUMENTING_MODULE_NAME = "schedex"
24
+
25
+
26
+ def build_job_schedule_tracing_middleware[SchT: Schedule](
27
+ tracer_provider: Optional[otl.trace.TracerProvider] = None,
28
+ propagator: Optional[otl.propagators.textmap.TextMapPropagator] = None,
29
+ ) -> SchedulerMiddleware[SchT]:
30
+ """
31
+ Builds a middleware for job scheduling tracing.
32
+
33
+ :param tracer_provider: tracer provider. If not passed the global one will be used.
34
+ :param propagator: trace propagator. If not passed the global one will be used.
35
+ """
36
+
37
+ tracer_provider = tracer_provider or otl.trace.get_tracer_provider()
38
+ tracer = tracer_provider.get_tracer(INSTRUMENTING_MODULE_NAME)
39
+
40
+ propagator = propagator or otl.propagate.get_global_textmap()
41
+
42
+ async def trace_job_schedule(
43
+ job: Job[SchT],
44
+ /,
45
+ *,
46
+ wrapped: SchedulerMiddlewareWrappedFunc[SchT],
47
+ ) -> Optional[str]:
48
+ attributes: types.Attributes = {
49
+ "scheduler.job.id": job.id,
50
+ "scheduler.job.name": job.task.name,
51
+ }
52
+
53
+ with tracer.start_as_current_span(job.task.name, kind=otl.trace.SpanKind.PRODUCER, attributes=attributes):
54
+ propagator.inject(job.meta)
55
+ return await wrapped(job)
56
+
57
+ return trace_job_schedule
58
+
59
+
60
+ def build_job_schedule_metrics_collecting_middleware[SchT: Schedule](
61
+ meter_provider: Optional[otl.metrics.MeterProvider] = None,
62
+ ) -> SchedulerMiddleware[SchT]:
63
+ """
64
+ Builds a middleware for job scheduling metrics collecting.
65
+
66
+ :param meter_provider: metrics provider. If not passed the global one will be used.
67
+ """
68
+
69
+ meter_provider = meter_provider or otl.metrics.get_meter_provider()
70
+ meter = meter_provider.get_meter(INSTRUMENTING_MODULE_NAME)
71
+
72
+ publish_counter = meter.create_up_down_counter(
73
+ name="scheduler.submit.jobs",
74
+ unit="jobs",
75
+ description="Measures the total number of scheduled jobs",
76
+ )
77
+ duration_histogram = meter.create_histogram(
78
+ name="scheduler.submit.duration",
79
+ unit="ms",
80
+ description="Measures the duration of scheduling",
81
+ )
82
+
83
+ async def collect_metrics(
84
+ job: Job[SchT],
85
+ /,
86
+ *,
87
+ wrapped: SchedulerMiddlewareWrappedFunc[SchT],
88
+ ) -> Optional[str]:
89
+ attributes: types.Attributes = {
90
+ "scheduler.job.name": job.task.name,
91
+ }
92
+
93
+ publish_counter.add(amount=1, attributes=attributes)
94
+
95
+ started_at = timeit.default_timer()
96
+ try:
97
+ return await wrapped(job)
98
+ finally:
99
+ duration_histogram.record(amount=timeit.default_timer() - started_at, attributes=attributes)
100
+
101
+ return collect_metrics
102
+
103
+
104
+ def build_task_execution_tracing_middleware[CtxT: Context[Any, Any]](
105
+ tracer_provider: Optional[otl.trace.TracerProvider] = None,
106
+ propagator: Optional[otl.propagators.textmap.TextMapPropagator] = None,
107
+ ) -> ExecutorMiddleware[CtxT]:
108
+ """
109
+ Builds a middleware for tracing scheduler task executions.
110
+
111
+ :param tracer_provider: tracer provider. If not passed the global one will be used.
112
+ :param propagator: trace propagator. If not passed the global one will be used.
113
+ """
114
+
115
+ tracer_provider = tracer_provider or otl.trace.get_tracer_provider()
116
+ tracer = tracer_provider.get_tracer(INSTRUMENTING_MODULE_NAME)
117
+
118
+ propagator = propagator or otl.propagate.get_global_textmap()
119
+
120
+ def copy_baggage(
121
+ src_context: otl.context.Context, dst_context: Optional[otl.context.Context] = None
122
+ ) -> otl.context.Context:
123
+ dst_context = dst_context or otl.context.get_current()
124
+
125
+ src_baggage = otl.baggage.get_all(src_context)
126
+ for key, value in src_baggage.items():
127
+ dst_context = otl.baggage.set_baggage(key, value, dst_context)
128
+
129
+ return dst_context
130
+
131
+ async def trace_task_execution(
132
+ context: CtxT,
133
+ task: Task[CtxT, Any, Any],
134
+ /,
135
+ *,
136
+ wrapped: ExecutorMiddlewareWrappedFunc[CtxT],
137
+ ) -> None:
138
+ attributes: types.Attributes = {
139
+ "scheduler.job.id": context.task.job_id,
140
+ "scheduler.task.id": context.task.id,
141
+ "scheduler.task.name": task.name,
142
+ }
143
+
144
+ propagated_context = propagator.extract(context.task.meta)
145
+ linked_span = otl.trace.get_current_span(propagated_context)
146
+ linked_context = linked_span.get_span_context()
147
+
148
+ token = otl.context.attach(copy_baggage(propagated_context))
149
+ try:
150
+ with tracer.start_as_current_span(
151
+ task.name,
152
+ links=[otl.trace.Link(linked_context)],
153
+ kind=otl.trace.SpanKind.CONSUMER,
154
+ attributes=attributes,
155
+ ):
156
+ return await wrapped(context, task)
157
+ finally:
158
+ otl.context.detach(token)
159
+
160
+ return trace_task_execution
161
+
162
+
163
+ def build_task_execution_metrics_collecting_middleware[CtxT: Context[Any, Any]](
164
+ meter_provider: Optional[otl.metrics.MeterProvider] = None,
165
+ ) -> ExecutorMiddleware[CtxT]:
166
+ """
167
+ Builds a middleware for task execution metrics collecting.
168
+
169
+ :param meter_provider: metrics provider. If not passed the global one will be used.
170
+ """
171
+
172
+ meter_provider = meter_provider or otl.metrics.get_meter_provider()
173
+ meter = meter_provider.get_meter(INSTRUMENTING_MODULE_NAME)
174
+
175
+ publish_counter = meter.create_up_down_counter(
176
+ name="scheduler.execution.tasks",
177
+ unit="tasks",
178
+ description="Measures the total number of scheduler executed tasks",
179
+ )
180
+ error_counter = meter.create_counter(
181
+ name="scheduler.execution.errors",
182
+ unit="errors",
183
+ description="Measures the number of scheduler task execution errors",
184
+ )
185
+ duration_histogram = meter.create_histogram(
186
+ name="scheduler.execution.duration",
187
+ unit="ms",
188
+ description="Measures the duration of scheduler task execution",
189
+ )
190
+
191
+ async def collect_metrics(
192
+ context: CtxT,
193
+ task: Task[CtxT, Any, Any],
194
+ /,
195
+ *,
196
+ wrapped: ExecutorMiddlewareWrappedFunc[CtxT],
197
+ ) -> None:
198
+ attributes: types.Attributes = {
199
+ "scheduler.task.name": task.name,
200
+ }
201
+
202
+ publish_counter.add(amount=1, attributes=attributes)
203
+
204
+ started_at = timeit.default_timer()
205
+ try:
206
+ return await wrapped(context, task)
207
+ except Exception:
208
+ error_counter.add(amount=1, attributes=attributes)
209
+ raise
210
+ finally:
211
+ duration_histogram.record(amount=timeit.default_timer() - started_at, attributes=attributes)
212
+
213
+ return collect_metrics
@@ -0,0 +1,114 @@
1
+ import base64
2
+ import contextlib as cl
3
+ import logging
4
+ from types import TracebackType
5
+ from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Self
6
+
7
+ import psycopg as ppg
8
+ import psycopg_pool as ppg_pool
9
+ from psycopg import sql
10
+
11
+ from schedex import Event, EventReceiver, EventSender
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class PsycopgEventSender(EventSender):
17
+ """
18
+ Psycopg event sender.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ pool: ppg_pool.AsyncConnectionPool,
24
+ channel: str = "schedex_events",
25
+ encoder: Callable[[bytes], str] = lambda d: base64.b64encode(d).decode(),
26
+ ):
27
+ self._pool = pool
28
+ self._channel = channel
29
+ self._encoder = encoder
30
+
31
+ async def send(self, event: Event) -> None:
32
+ serialized = event.serialize()
33
+ encoded = self._encoder(serialized)
34
+
35
+ async with self._pool.connection() as conn:
36
+ await conn.execute("SELECT pg_notify(%s, %s)", (self._channel, encoded))
37
+ logger.debug("sent event: %s", event)
38
+
39
+
40
+ class EventSource(AsyncIterator[Event]):
41
+ """
42
+ Psycopg event source.
43
+
44
+ :param conn: database connection
45
+ :param decoder: binary data decoder
46
+ """
47
+
48
+ def __init__(self, conn: ppg.AsyncConnection[Any], decoder: Callable[[str], bytes]):
49
+ self._conn = conn
50
+ self._notifies = conn.notifies()
51
+ self._decoder = decoder
52
+
53
+ async def __aenter__(self) -> Self:
54
+ return self
55
+
56
+ async def __aexit__(
57
+ self,
58
+ exc_type: Optional[type[BaseException]],
59
+ exc_value: Optional[BaseException],
60
+ traceback: Optional[TracebackType],
61
+ ) -> Optional[bool]:
62
+ await self._notifies.aclose()
63
+ return False
64
+
65
+ def __aiter__(self) -> Self:
66
+ return self
67
+
68
+ async def __anext__(self) -> Event:
69
+ try:
70
+ notification = await anext(self._notifies)
71
+ except StopAsyncIteration:
72
+ # reconfigure notifies generator if it had been stopped
73
+ self._notifies = self._conn.notifies()
74
+ notification = await anext(self._notifies)
75
+
76
+ decoded = self._decoder(notification.payload)
77
+ event = Event.deserialize(decoded)
78
+ logger.debug("received event: %s", event)
79
+
80
+ return event
81
+
82
+
83
+ class PsycopgEventReceiver(EventReceiver):
84
+ """
85
+ Psycopg event receiver.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ pool: ppg_pool.AsyncConnectionPool,
91
+ channel: str = "schedex_events",
92
+ decoder: Callable[[str], bytes] = base64.b64decode,
93
+ ):
94
+ self._pool = pool
95
+ self._channel = channel
96
+ self._decoder = decoder
97
+
98
+ @cl.asynccontextmanager
99
+ async def connect(self) -> AsyncGenerator[EventSource, None]:
100
+ logger.info("connecting to event source ...")
101
+
102
+ async with self._pool.connection() as conn:
103
+ await conn.set_autocommit(True)
104
+
105
+ await conn.execute(sql.SQL("LISTEN {0}").format(sql.Identifier(self._channel)))
106
+ try:
107
+ async with EventSource(conn, self._decoder) as source:
108
+ logger.info("event source configured")
109
+ yield source
110
+
111
+ finally:
112
+ await conn.execute(sql.SQL("UNLISTEN {0}").format(sql.Identifier(self._channel)))
113
+
114
+ logger.info("event source connection closed")
@@ -0,0 +1,16 @@
1
+ from .lock import (
2
+ SqlAlchemyLeasingJobLock,
3
+ SqlAlchemyLeasingLockManager,
4
+ SqlAlchemyLeasingTaskLock,
5
+ SqlAlchemySfuJobLock,
6
+ SqlAlchemySfuLockManager,
7
+ SqlAlchemySfuTaskLock,
8
+ )
9
+ from .scheduler import SqlAlchemyScheduler, SqlAlchemyTransactionalScheduler
10
+ from .storage import (
11
+ SqlAlchemyJobManager,
12
+ SqlAlchemyOwnedTransaction,
13
+ SqlAlchemyTransaction,
14
+ SqlAlchemyTransactionManager,
15
+ )
16
+ from .tables import BaseModel