pgwerk 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.
pgwerk/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ from .app import Werk
2
+ from .cron import CronJob
3
+ from .cron import CronScheduler
4
+ from .config import WerkConfig
5
+ from .worker import BaseWorker
6
+ from .worker import ForkWorker
7
+ from .worker import AsyncWorker
8
+ from .worker import ThreadWorker
9
+ from .worker import ProcessWorker
10
+ from .commons import JobStatus
11
+ from .commons import FailureMode
12
+ from .commons import DequeueStrategy
13
+ from .commons import ExecutionStatus
14
+ from .logging import configure_logging
15
+ from .schemas import Job
16
+ from .schemas import Retry
17
+ from .schemas import Repeat
18
+ from .schemas import Context
19
+ from .schemas import Callback
20
+ from .schemas import Dependency
21
+ from .schemas import JobExecution
22
+ from .schemas import EnqueueParams
23
+ from .exceptions import JobError
24
+ from .exceptions import WerkError
25
+ from .exceptions import JobTimeout
26
+ from .exceptions import JobNotFound
27
+ from .exceptions import DependencyFailed
28
+ from .serializers import Serializer
29
+ from .serializers import JSONSerializer
30
+ from .serializers import PickleSerializer
31
+
32
+
33
+ __all__ = [
34
+ # App
35
+ "Werk",
36
+ "WerkConfig",
37
+ "EnqueueParams",
38
+ # Job types
39
+ "Job",
40
+ "JobExecution",
41
+ "JobStatus",
42
+ "ExecutionStatus",
43
+ "Retry",
44
+ "Dependency",
45
+ "Callback",
46
+ "Repeat",
47
+ # Workers
48
+ "BaseWorker",
49
+ "AsyncWorker",
50
+ "ThreadWorker",
51
+ "ProcessWorker",
52
+ "ForkWorker",
53
+ "DequeueStrategy",
54
+ "FailureMode",
55
+ # Scheduler
56
+ "CronJob",
57
+ "CronScheduler",
58
+ # Serializers
59
+ "Serializer",
60
+ "JSONSerializer",
61
+ "PickleSerializer",
62
+ # Logging
63
+ "configure_logging",
64
+ # Exceptions
65
+ "WerkError",
66
+ "JobNotFound",
67
+ "JobTimeout",
68
+ "JobError",
69
+ "DependencyFailed",
70
+ # Context
71
+ "Context",
72
+ ]
pgwerk/api/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .app import create_app
2
+
3
+
4
+ __all__ = ["create_app"]
pgwerk/api/app.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import logging
5
+ import pathlib
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from litestar import Request
10
+ from litestar import Litestar
11
+ from litestar import Response
12
+ from litestar import get
13
+ from litestar.di import Provide
14
+ from litestar.static_files import StaticFilesConfig
15
+ from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
16
+
17
+
18
+ _STATIC_DIR = pathlib.Path(__file__).parent / "static"
19
+
20
+
21
+ def _static_config() -> list[StaticFilesConfig]:
22
+ if (_STATIC_DIR / "index.html").exists():
23
+ return [StaticFilesConfig(directories=[_STATIC_DIR], path="/", html_mode=True)]
24
+ return []
25
+
26
+
27
+ from ..app import Werk # noqa: E402
28
+ from .routes import router # noqa: E402
29
+
30
+
31
+ if TYPE_CHECKING:
32
+ from ..exporter import WerkExporter
33
+
34
+
35
+ logger = logging.getLogger("pgwerk.api")
36
+
37
+
38
+ def _server_error_handler(request: Request, exc: Exception) -> Response:
39
+ logger.exception("Unhandled exception on %s %s", request.method, request.url.path, exc_info=exc)
40
+ return Response(
41
+ content={"detail": "Internal server error"},
42
+ status_code=HTTP_500_INTERNAL_SERVER_ERROR,
43
+ )
44
+
45
+
46
+ async def _create_pgwerk() -> Werk:
47
+ dsn = os.environ["PGWERK_DSN"]
48
+ app = Werk(dsn)
49
+ await app.connect()
50
+ return app
51
+
52
+
53
+ async def _close_pgwerk(werk: Werk) -> None:
54
+ await werk.disconnect()
55
+
56
+
57
+ def create_app(
58
+ werk: Werk | None = None,
59
+ exporter: "WerkExporter | None" = None,
60
+ exporter_interval: float | None = None,
61
+ ) -> Litestar:
62
+ """Create the Litestar observability app.
63
+
64
+ Pass an already-connected ``Werk`` instance, or set ``PGWERK_DSN`` in the
65
+ environment and one will be created on startup.
66
+
67
+ To serve Prometheus metrics at ``GET /metrics`` on the same server:
68
+ - pass a pre-built ``WerkExporter`` via *exporter*, or
69
+ - pass *exporter_interval* (seconds) and one will be created automatically.
70
+ """
71
+ dependencies: dict = {}
72
+ on_startup = []
73
+ on_shutdown = []
74
+
75
+ if werk is not None:
76
+ dependencies["werk"] = Provide(lambda: werk, use_cache=True, sync_to_thread=False)
77
+
78
+ # Exporter needs the Werk instance — create it now if only interval was given.
79
+ if exporter is None and exporter_interval is not None:
80
+ from ..exporter import WerkExporter
81
+
82
+ exporter = WerkExporter(werk, interval=exporter_interval)
83
+ else:
84
+ _state: dict = {}
85
+
86
+ async def _startup() -> None:
87
+ _state["werk"] = await _create_pgwerk()
88
+ if exporter_interval is not None and "exporter" not in _state:
89
+ from ..exporter import WerkExporter
90
+
91
+ _state["exporter"] = WerkExporter(_state["werk"], interval=exporter_interval)
92
+ await _state["exporter"].start()
93
+
94
+ async def _shutdown() -> None:
95
+ if "exporter" in _state:
96
+ await _state["exporter"].stop()
97
+ if "werk" in _state:
98
+ await _close_pgwerk(_state["werk"])
99
+
100
+ async def _get_pgwerk() -> Werk:
101
+ return _state["werk"]
102
+
103
+ dependencies["werk"] = Provide(_get_pgwerk, use_cache=True, sync_to_thread=False)
104
+ on_startup.append(_startup)
105
+ on_shutdown.append(_shutdown)
106
+
107
+ # Metrics handler for the env-var path — reads exporter from _state at request time.
108
+ if exporter_interval is not None:
109
+
110
+ @get("/metrics", media_type="text/plain", sync_to_thread=False)
111
+ async def _metrics_from_state() -> Response[bytes]:
112
+ exp = _state.get("exporter")
113
+ if exp is None:
114
+ return Response(content=b"", media_type="text/plain", status_code=503)
115
+ body, content_type = exp.metrics_bytes()
116
+ return Response(content=body, media_type=content_type)
117
+
118
+ return Litestar(
119
+ route_handlers=[router, _metrics_from_state],
120
+ static_files_config=_static_config(),
121
+ dependencies=dependencies,
122
+ on_startup=on_startup,
123
+ on_shutdown=on_shutdown,
124
+ exception_handlers={Exception: _server_error_handler},
125
+ )
126
+
127
+ route_handlers: list = [router]
128
+
129
+ if exporter is not None:
130
+ _exporter = exporter
131
+
132
+ @get("/metrics", media_type="text/plain", sync_to_thread=False)
133
+ async def metrics_handler() -> Response[bytes]:
134
+ body, content_type = _exporter.metrics_bytes()
135
+ return Response(content=body, media_type=content_type)
136
+
137
+ route_handlers.append(metrics_handler)
138
+ on_startup.append(_exporter.start)
139
+ on_shutdown.append(_exporter.stop)
140
+
141
+ return Litestar(
142
+ route_handlers=route_handlers,
143
+ static_files_config=_static_config(),
144
+ dependencies=dependencies,
145
+ on_startup=on_startup,
146
+ on_shutdown=on_shutdown,
147
+ exception_handlers={Exception: _server_error_handler},
148
+ )
pgwerk/api/models.py ADDED
@@ -0,0 +1,458 @@
1
+ """Response models and request bodies for the wrk REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+
7
+ from typing import TYPE_CHECKING
8
+ from typing import Any
9
+ from datetime import datetime
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from ..schemas import Job
14
+ from ..schemas import JobExecution
15
+
16
+
17
+ @dataclasses.dataclass
18
+ class JobResponse:
19
+ """Serialisable view of a job row returned by the API.
20
+
21
+ Attributes:
22
+ id: UUID of the job.
23
+ function: Dotted import path of the job handler.
24
+ queue: Queue the job belongs to.
25
+ status: Current lifecycle state as a plain string.
26
+ priority: Dequeue priority.
27
+ attempts: Number of execution attempts so far.
28
+ max_attempts: Maximum total attempts.
29
+ scheduled_at: Earliest time the job may be dequeued.
30
+ enqueued_at: Wall-clock insertion time.
31
+ key: Optional deduplication key.
32
+ group_key: Optional group concurrency key.
33
+ error: Error message or traceback from the last failed attempt.
34
+ timeout_secs: Hard per-attempt timeout in seconds.
35
+ heartbeat_secs: Maximum heartbeat gap before the job is considered lost.
36
+ started_at: When the most recent attempt began.
37
+ completed_at: When the job reached a terminal state.
38
+ worker_id: UUID of the worker currently or last executing this job.
39
+ meta: User-supplied metadata.
40
+ """
41
+
42
+ id: str
43
+ function: str
44
+ queue: str
45
+ status: str
46
+ priority: int
47
+ attempts: int
48
+ max_attempts: int
49
+ scheduled_at: datetime
50
+ enqueued_at: datetime
51
+ key: str | None = None
52
+ group_key: str | None = None
53
+ error: str | None = None
54
+ timeout_secs: int | None = None
55
+ heartbeat_secs: int | None = None
56
+ started_at: datetime | None = None
57
+ completed_at: datetime | None = None
58
+ worker_id: str | None = None
59
+ meta: dict[str, Any] | None = None
60
+
61
+ @classmethod
62
+ def from_job(cls, job: "Job") -> "JobResponse":
63
+ """Construct a JobResponse from a :class:`~wrk.schemas.Job` instance.
64
+
65
+ Args:
66
+ job: The source Job domain object.
67
+
68
+ Returns:
69
+ A JobResponse suitable for JSON serialisation.
70
+ """
71
+ return cls(
72
+ id=job.id,
73
+ function=job.function,
74
+ queue=job.queue,
75
+ status=str(job.status.value),
76
+ priority=job.priority,
77
+ attempts=job.attempts,
78
+ max_attempts=job.max_attempts,
79
+ scheduled_at=job.scheduled_at,
80
+ enqueued_at=job.enqueued_at,
81
+ key=job.key,
82
+ group_key=job.group_key,
83
+ error=job.error,
84
+ timeout_secs=job.timeout_secs,
85
+ heartbeat_secs=job.heartbeat_secs,
86
+ started_at=job.started_at,
87
+ completed_at=job.completed_at,
88
+ worker_id=job.worker_id,
89
+ meta=job.meta,
90
+ )
91
+
92
+
93
+ @dataclasses.dataclass
94
+ class ExecutionResponse:
95
+ """Serialisable view of a single job execution attempt.
96
+
97
+ Attributes:
98
+ id: UUID of the execution row.
99
+ job_id: UUID of the parent job.
100
+ attempt: 1-based attempt number.
101
+ status: Current or final state of this execution.
102
+ worker_id: UUID of the worker that ran this attempt.
103
+ error: Error message or traceback if the attempt failed.
104
+ started_at: When this attempt began.
105
+ completed_at: When this attempt reached a terminal state.
106
+ """
107
+
108
+ id: str
109
+ job_id: str
110
+ attempt: int
111
+ status: str
112
+ worker_id: str | None = None
113
+ error: str | None = None
114
+ started_at: datetime | None = None
115
+ completed_at: datetime | None = None
116
+
117
+ @classmethod
118
+ def from_execution(cls, e: "JobExecution") -> "ExecutionResponse":
119
+ """Construct an ExecutionResponse from a :class:`~wrk.schemas.JobExecution`.
120
+
121
+ Args:
122
+ e: The source JobExecution domain object.
123
+
124
+ Returns:
125
+ An ExecutionResponse suitable for JSON serialisation.
126
+ """
127
+ return cls(
128
+ id=e.id,
129
+ job_id=e.job_id,
130
+ attempt=e.attempt,
131
+ status=str(e.status.value),
132
+ worker_id=e.worker_id,
133
+ error=e.error,
134
+ started_at=e.started_at,
135
+ completed_at=e.completed_at,
136
+ )
137
+
138
+
139
+ @dataclasses.dataclass
140
+ class WorkerResponse:
141
+ """Serialisable view of a registered worker row.
142
+
143
+ Attributes:
144
+ id: UUID of the worker.
145
+ name: Human-readable ``hostname.pid`` identifier.
146
+ queue: Primary queue the worker is consuming.
147
+ status: Current worker status (e.g. ``"idle"``, ``"busy"``).
148
+ metadata: JSON blob with runtime info (PID, concurrency, strategy, etc.).
149
+ heartbeat_at: Timestamp of the last heartbeat.
150
+ started_at: When the worker registered.
151
+ expires_at: Time after which the worker row may be cleaned up.
152
+ """
153
+
154
+ id: str
155
+ name: str
156
+ queue: str
157
+ status: str
158
+ metadata: dict[str, Any] | None = None
159
+ heartbeat_at: datetime | None = None
160
+ started_at: datetime | None = None
161
+ expires_at: datetime | None = None
162
+
163
+ @classmethod
164
+ def from_row(cls, r: dict[str, Any]) -> "WorkerResponse":
165
+ """Construct a WorkerResponse from a raw database row dict.
166
+
167
+ Args:
168
+ r: Database row as a mapping of column names to values.
169
+
170
+ Returns:
171
+ A WorkerResponse suitable for JSON serialisation.
172
+ """
173
+ return cls(
174
+ id=r["id"],
175
+ name=r["name"],
176
+ queue=r["queue"],
177
+ status=r["status"],
178
+ metadata=r["metadata"],
179
+ heartbeat_at=r["heartbeat_at"],
180
+ started_at=r["started_at"],
181
+ expires_at=r["expires_at"],
182
+ )
183
+
184
+
185
+ @dataclasses.dataclass
186
+ class QueueStats:
187
+ """Per-queue job counts broken down by status.
188
+
189
+ Attributes:
190
+ queue: Name of the queue.
191
+ scheduled: Jobs waiting for their ``scheduled_at`` to arrive.
192
+ queued: Jobs ready for immediate dequeue.
193
+ active: Jobs currently being executed.
194
+ waiting: Jobs blocked on dependencies.
195
+ failed: Jobs that exhausted all retry attempts.
196
+ complete: Successfully completed jobs.
197
+ aborted: Jobs that were manually aborted.
198
+ """
199
+
200
+ queue: str
201
+ scheduled: int
202
+ queued: int
203
+ active: int
204
+ waiting: int
205
+ failed: int
206
+ complete: int
207
+ aborted: int
208
+
209
+ @classmethod
210
+ def from_row(cls, r: dict[str, Any]) -> "QueueStats":
211
+ """Construct QueueStats from a raw database row dict.
212
+
213
+ Args:
214
+ r: Database row as a mapping of column names to integer counts.
215
+
216
+ Returns:
217
+ A QueueStats instance.
218
+ """
219
+ return cls(
220
+ queue=r["queue"],
221
+ scheduled=r["scheduled"],
222
+ queued=r["queued"],
223
+ active=r["active"],
224
+ waiting=r["waiting"],
225
+ failed=r["failed"],
226
+ complete=r["complete"],
227
+ aborted=r["aborted"],
228
+ )
229
+
230
+
231
+ @dataclasses.dataclass
232
+ class StatsResponse:
233
+ """Aggregate statistics response returned by the stats endpoint.
234
+
235
+ Attributes:
236
+ queues: Per-queue job counts.
237
+ total_jobs: Total number of job rows across all queues.
238
+ workers_online: Number of workers with a recent heartbeat.
239
+ """
240
+
241
+ queues: list[QueueStats]
242
+ total_jobs: int
243
+ workers_online: int
244
+
245
+
246
+ @dataclasses.dataclass
247
+ class WorkerThroughputPoint:
248
+ """A single data point in a worker throughput time series.
249
+
250
+ Attributes:
251
+ time: Truncated timestamp for this bucket (e.g. per-minute).
252
+ worker_id: UUID of the worker, or ``None`` for an aggregate row.
253
+ worker_name: Human-readable worker name, or ``None`` for an aggregate row.
254
+ count: Number of jobs completed in this bucket.
255
+ """
256
+
257
+ time: datetime
258
+ worker_id: str | None
259
+ worker_name: str | None
260
+ count: int
261
+
262
+ @classmethod
263
+ def from_row(cls, r: dict[str, Any]) -> "WorkerThroughputPoint":
264
+ """Construct a WorkerThroughputPoint from a raw database row dict.
265
+
266
+ Args:
267
+ r: Database row with keys ``time``, ``worker_id``, ``worker_name``,
268
+ and ``count``.
269
+
270
+ Returns:
271
+ A WorkerThroughputPoint instance.
272
+ """
273
+ return cls(
274
+ time=r["time"],
275
+ worker_id=r["worker_id"],
276
+ worker_name=r["worker_name"],
277
+ count=r["count"],
278
+ )
279
+
280
+
281
+ @dataclasses.dataclass
282
+ class QueueDepthPoint:
283
+ """A single data point in a queue depth time series.
284
+
285
+ Attributes:
286
+ time: Truncated timestamp for this bucket (e.g. per-minute).
287
+ queued: Number of jobs in ``queued`` status at this point in time.
288
+ active: Number of jobs in ``active`` status at this point in time.
289
+ """
290
+
291
+ time: datetime
292
+ queued: int
293
+ active: int
294
+
295
+ @classmethod
296
+ def from_row(cls, r: dict[str, Any]) -> "QueueDepthPoint":
297
+ """Construct a QueueDepthPoint from a raw database row dict.
298
+
299
+ Args:
300
+ r: Database row with keys ``time``, ``queued``, and ``active``.
301
+
302
+ Returns:
303
+ A QueueDepthPoint instance.
304
+ """
305
+ return cls(
306
+ time=r["time"],
307
+ queued=r["queued"],
308
+ active=r["active"],
309
+ )
310
+
311
+
312
+ @dataclasses.dataclass
313
+ class TableInfo:
314
+ """Size and row-count metadata for a single wrk database table.
315
+
316
+ Attributes:
317
+ name: Fully-qualified table name.
318
+ size_bytes: Total on-disk size of the table in bytes.
319
+ row_count: Estimated number of rows (from ``pg_class``).
320
+ """
321
+
322
+ name: str
323
+ size_bytes: int
324
+ row_count: int
325
+
326
+
327
+ @dataclasses.dataclass
328
+ class ServerInfo:
329
+ """Server-level information returned by the server info endpoint.
330
+
331
+ Attributes:
332
+ pg_version: Postgres server version string.
333
+ db_size_bytes: Total database size in bytes.
334
+ tables: Per-table size and row-count metadata.
335
+ """
336
+
337
+ pg_version: str
338
+ db_size_bytes: int
339
+ tables: list[TableInfo]
340
+
341
+
342
+ @dataclasses.dataclass
343
+ class PurgeRequest:
344
+ """Request body for the bulk job purge endpoint.
345
+
346
+ Attributes:
347
+ statuses: Job status values to purge (defaults to ``complete``,
348
+ ``failed``, and ``aborted``).
349
+ older_than_days: Only delete jobs that completed more than this many
350
+ days ago.
351
+ """
352
+
353
+ statuses: list[str] = dataclasses.field(default_factory=lambda: ["complete", "failed", "aborted"])
354
+ older_than_days: int = 30
355
+
356
+
357
+ @dataclasses.dataclass
358
+ class BulkRequeueRequest:
359
+ """Request body for the bulk requeue endpoint.
360
+
361
+ Attributes:
362
+ queue: Limit requeue to this queue; ``None`` targets all queues.
363
+ function_name: Limit requeue to jobs with this function name.
364
+ """
365
+
366
+ queue: str | None = None
367
+ function_name: str | None = None
368
+
369
+
370
+ @dataclasses.dataclass
371
+ class BulkCancelRequest:
372
+ """Request body for the bulk cancel endpoint.
373
+
374
+ Attributes:
375
+ queue: Limit cancellation to this queue; ``None`` targets all queues.
376
+ """
377
+
378
+ queue: str | None = None
379
+
380
+
381
+ @dataclasses.dataclass
382
+ class EnqueueRequest:
383
+ """Request body for the single-job enqueue endpoint.
384
+
385
+ Attributes:
386
+ function: Dotted import path of the handler to invoke.
387
+ queue: Target queue name.
388
+ priority: Dequeue priority — higher values are processed first.
389
+ args: Positional arguments forwarded to the handler.
390
+ kwargs: Keyword arguments forwarded to the handler.
391
+ key: Optional deduplication key; duplicate enqueues are silently dropped.
392
+ delay: Seconds to wait before the job becomes eligible for dequeue.
393
+ scheduled_at: Absolute time at which the job becomes eligible; overrides
394
+ ``delay`` when both are set.
395
+ max_attempts: Maximum total execution attempts.
396
+ timeout_secs: Hard per-attempt wall-clock timeout in seconds.
397
+ meta: Arbitrary user-supplied metadata stored with the job.
398
+ cron_name: Cron job name to associate with this enqueue.
399
+ """
400
+
401
+ function: str
402
+ queue: str = "default"
403
+ priority: int = 0
404
+ args: list[Any] = dataclasses.field(default_factory=list)
405
+ kwargs: dict[str, Any] = dataclasses.field(default_factory=dict)
406
+ key: str | None = None
407
+ delay: int | None = None
408
+ scheduled_at: datetime | None = None
409
+ max_attempts: int = 1
410
+ timeout_secs: int | None = None
411
+ meta: dict[str, Any] | None = None
412
+ cron_name: str | None = None
413
+
414
+
415
+ @dataclasses.dataclass
416
+ class CronJobStats:
417
+ """Aggregated statistics for a single registered cron job.
418
+
419
+ Attributes:
420
+ name: Cron job name (``module.qualname`` by default).
421
+ function: Dotted import path of the handler.
422
+ queue: Queue the cron job enqueues into.
423
+ total_runs: Total number of times the job has been enqueued.
424
+ failed_runs: Number of runs that ended in a failed or aborted state.
425
+ last_status: Status of the most recent execution.
426
+ last_enqueued_at: Timestamp of the most recent enqueue.
427
+ last_completed_at: Timestamp of the most recent completion.
428
+ """
429
+
430
+ name: str
431
+ function: str
432
+ queue: str
433
+ total_runs: int
434
+ failed_runs: int
435
+ last_status: str | None = None
436
+ last_enqueued_at: datetime | None = None
437
+ last_completed_at: datetime | None = None
438
+
439
+ @classmethod
440
+ def from_row(cls, r: dict[str, Any]) -> "CronJobStats":
441
+ """Construct CronJobStats from a raw database row dict.
442
+
443
+ Args:
444
+ r: Database row with aggregated cron statistics columns.
445
+
446
+ Returns:
447
+ A CronJobStats instance.
448
+ """
449
+ return cls(
450
+ name=r["cron_name"],
451
+ function=r["function"],
452
+ queue=r["queue"],
453
+ total_runs=r["total_runs"],
454
+ failed_runs=r["failed_runs"],
455
+ last_status=r["last_status"],
456
+ last_enqueued_at=r["last_enqueued_at"],
457
+ last_completed_at=r["last_completed_at"],
458
+ )