plain.jobs 0.33.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.
Potentially problematic release.
This version of plain.jobs might be problematic. Click here for more details.
- plain/jobs/CHANGELOG.md +186 -0
- plain/jobs/README.md +253 -0
- plain/jobs/__init__.py +4 -0
- plain/jobs/admin.py +238 -0
- plain/jobs/chores.py +17 -0
- plain/jobs/cli.py +153 -0
- plain/jobs/config.py +19 -0
- plain/jobs/default_settings.py +6 -0
- plain/jobs/jobs.py +226 -0
- plain/jobs/middleware.py +20 -0
- plain/jobs/migrations/0001_initial.py +246 -0
- plain/jobs/migrations/0002_job_span_id_job_trace_id_jobrequest_span_id_and_more.py +61 -0
- plain/jobs/migrations/0003_rename_job_jobprocess_and_more.py +80 -0
- plain/jobs/migrations/0004_rename_tables_to_plainjobs.py +33 -0
- plain/jobs/migrations/0005_rename_constraints_and_indexes.py +174 -0
- plain/jobs/migrations/0006_alter_jobprocess_table_alter_jobrequest_table_and_more.py +24 -0
- plain/jobs/migrations/__init__.py +0 -0
- plain/jobs/models.py +438 -0
- plain/jobs/parameters.py +193 -0
- plain/jobs/registry.py +60 -0
- plain/jobs/scheduling.py +251 -0
- plain/jobs/templates/admin/plainqueue/jobresult_detail.html +8 -0
- plain/jobs/workers.py +322 -0
- plain_jobs-0.33.0.dist-info/METADATA +264 -0
- plain_jobs-0.33.0.dist-info/RECORD +27 -0
- plain_jobs-0.33.0.dist-info/WHEEL +4 -0
- plain_jobs-0.33.0.dist-info/licenses/LICENSE +28 -0
plain/jobs/CHANGELOG.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# plain-jobs changelog
|
|
2
|
+
|
|
3
|
+
## [0.33.0](https://github.com/dropseed/plain/releases/plain-jobs@0.33.0) (2025-10-10)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Renamed package from `plain.worker` to `plain.jobs` ([24219856e0](https://github.com/dropseed/plain/commit/24219856e0))
|
|
8
|
+
|
|
9
|
+
### Upgrade instructions
|
|
10
|
+
|
|
11
|
+
- Update any imports from `plain.worker` to `plain.jobs` (e.g., `from plain.worker import Job` becomes `from plain.jobs import Job`)
|
|
12
|
+
- Change worker commands from `plain worker run` to `plain jobs worker`
|
|
13
|
+
- Check updated settings names
|
|
14
|
+
|
|
15
|
+
## [0.32.0](https://github.com/dropseed/plain/releases/plain-jobs@0.32.0) (2025-10-07)
|
|
16
|
+
|
|
17
|
+
### What's changed
|
|
18
|
+
|
|
19
|
+
- Models now use `model_options` instead of `_meta` for accessing model configuration like `package_label` and `model_name` ([73ba469](https://github.com/dropseed/plain/commit/73ba469ba0))
|
|
20
|
+
- Model configuration now uses `model_options = models.Options()` instead of `class Meta` ([17a378d](https://github.com/dropseed/plain/commit/17a378dcfb))
|
|
21
|
+
- QuerySet types now properly use `Self` return type for better type checking ([2578301](https://github.com/dropseed/plain/commit/2578301819))
|
|
22
|
+
- Removed unnecessary type ignore comments now that QuerySet is properly typed ([2578301](https://github.com/dropseed/plain/commit/2578301819))
|
|
23
|
+
|
|
24
|
+
### Upgrade instructions
|
|
25
|
+
|
|
26
|
+
- No changes required
|
|
27
|
+
|
|
28
|
+
## [0.31.1](https://github.com/dropseed/plain/releases/plain-jobs@0.31.1) (2025-10-06)
|
|
29
|
+
|
|
30
|
+
### What's changed
|
|
31
|
+
|
|
32
|
+
- Updated dependency resolution to use newer compatible versions of `plain` and `plain.models`
|
|
33
|
+
|
|
34
|
+
### Upgrade instructions
|
|
35
|
+
|
|
36
|
+
- No changes required
|
|
37
|
+
|
|
38
|
+
## [0.31.0](https://github.com/dropseed/plain/releases/plain-jobs@0.31.0) (2025-09-25)
|
|
39
|
+
|
|
40
|
+
### What's changed
|
|
41
|
+
|
|
42
|
+
- The jobs autodiscovery now includes `app.jobs` modules in addition to package jobs modules ([b0b610d](https://github.com/dropseed/plain/commit/b0b610d461))
|
|
43
|
+
|
|
44
|
+
### Upgrade instructions
|
|
45
|
+
|
|
46
|
+
- No changes required
|
|
47
|
+
|
|
48
|
+
## [0.30.0](https://github.com/dropseed/plain/releases/plain-jobs@0.30.0) (2025-09-19)
|
|
49
|
+
|
|
50
|
+
### What's changed
|
|
51
|
+
|
|
52
|
+
- The `Job` model has been renamed to `JobProcess` for better clarity ([986c914](https://github.com/dropseed/plain/commit/986c914))
|
|
53
|
+
- The `job_uuid` field in JobResult has been renamed to `job_process_uuid` to match the model rename ([986c914](https://github.com/dropseed/plain/commit/986c914))
|
|
54
|
+
- Admin interface now shows "Job processes" as the section title instead of "Jobs" ([986c914](https://github.com/dropseed/plain/commit/986c914))
|
|
55
|
+
|
|
56
|
+
### Upgrade instructions
|
|
57
|
+
|
|
58
|
+
- Run `plain migrate` to apply the database migration that renames the Job model to JobProcess
|
|
59
|
+
- If you have any custom code that directly references the `Job` model (different than the `Job` base class for job type definitions), update it to use `JobProcess` instead
|
|
60
|
+
- If you have any code that accesses the `job_uuid` field on JobResult instances, update it to use `job_process_uuid`
|
|
61
|
+
|
|
62
|
+
## [0.29.0](https://github.com/dropseed/plain/releases/plain-jobs@0.29.0) (2025-09-12)
|
|
63
|
+
|
|
64
|
+
### What's changed
|
|
65
|
+
|
|
66
|
+
- Model managers have been renamed from `.objects` to `.query` ([037a239](https://github.com/dropseed/plain/commit/037a239ef4))
|
|
67
|
+
- Manager functionality has been merged into QuerySet classes ([bbaee93](https://github.com/dropseed/plain/commit/bbaee93839))
|
|
68
|
+
- Models now use `Meta.queryset_class` instead of separate manager configuration ([6b60a00](https://github.com/dropseed/plain/commit/6b60a00731))
|
|
69
|
+
|
|
70
|
+
### Upgrade instructions
|
|
71
|
+
|
|
72
|
+
- Update all model queries to use `.query` instead of `.objects` (e.g., `Job.query.all()` becomes `Job.query.all()`)
|
|
73
|
+
|
|
74
|
+
## [0.28.1](https://github.com/dropseed/plain/releases/plain-jobs@0.28.1) (2025-09-10)
|
|
75
|
+
|
|
76
|
+
### What's changed
|
|
77
|
+
|
|
78
|
+
- Fixed log context method in worker middleware to use `include_context` instead of `with_context` ([755f873](https://github.com/dropseed/plain/commit/755f873986))
|
|
79
|
+
|
|
80
|
+
### Upgrade instructions
|
|
81
|
+
|
|
82
|
+
- No changes required
|
|
83
|
+
|
|
84
|
+
## [0.28.0](https://github.com/dropseed/plain/releases/plain-jobs@0.28.0) (2025-09-09)
|
|
85
|
+
|
|
86
|
+
### What's changed
|
|
87
|
+
|
|
88
|
+
- Improved logging middleware to use context manager pattern for cleaner job context handling ([ea7c953](https://github.com/dropseed/plain/commit/ea7c9537e3))
|
|
89
|
+
- Updated minimum Python requirement to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307efb))
|
|
90
|
+
- Added explicit nav_icon definitions to admin views to ensure consistent icon display ([2aac07d](https://github.com/dropseed/plain/commit/2aac07de4e))
|
|
91
|
+
|
|
92
|
+
### Upgrade instructions
|
|
93
|
+
|
|
94
|
+
- No changes required
|
|
95
|
+
|
|
96
|
+
## [0.27.1](https://github.com/dropseed/plain/releases/plain-jobs@0.27.1) (2025-08-27)
|
|
97
|
+
|
|
98
|
+
### What's changed
|
|
99
|
+
|
|
100
|
+
- Jobs are now marked as cancelled when the worker process is killed or fails unexpectedly ([e73ca53](https://github.com/dropseed/plain/commit/e73ca53c3d))
|
|
101
|
+
|
|
102
|
+
### Upgrade instructions
|
|
103
|
+
|
|
104
|
+
- No changes required
|
|
105
|
+
|
|
106
|
+
## [0.27.0](https://github.com/dropseed/plain/releases/plain-jobs@0.27.0) (2025-08-22)
|
|
107
|
+
|
|
108
|
+
### What's changed
|
|
109
|
+
|
|
110
|
+
- Added support for date and datetime job parameters with proper serialization/deserialization ([7bb5ab0911](https://github.com/dropseed/plain/commit/7bb5ab0911))
|
|
111
|
+
- Improved job priority documentation to clarify that higher numbers run first ([73271b5bf0](https://github.com/dropseed/plain/commit/73271b5bf0))
|
|
112
|
+
- Updated admin interface with consolidated navigation icons at the section level ([5a6479ac79](https://github.com/dropseed/plain/commit/5a6479ac79))
|
|
113
|
+
- Enhanced admin views to use cached object properties for better performance ([bd0507a72c](https://github.com/dropseed/plain/commit/bd0507a72c))
|
|
114
|
+
|
|
115
|
+
### Upgrade instructions
|
|
116
|
+
|
|
117
|
+
- No changes required
|
|
118
|
+
|
|
119
|
+
## [0.26.0](https://github.com/dropseed/plain/releases/plain-jobs@0.26.0) (2025-08-19)
|
|
120
|
+
|
|
121
|
+
### What's changed
|
|
122
|
+
|
|
123
|
+
- Improved CSRF token handling in admin forms by removing manual `csrf_input` in favor of automatic Sec-Fetch-Site header validation ([955150800c](https://github.com/dropseed/plain/commit/955150800c))
|
|
124
|
+
- Enhanced README documentation with comprehensive examples, table of contents, and detailed sections covering job parameters, scheduling, monitoring, and FAQs ([4ebecd1856](https://github.com/dropseed/plain/commit/4ebecd1856))
|
|
125
|
+
- Updated package description to be more descriptive: "Process background jobs with a database-driven worker" ([4ebecd1856](https://github.com/dropseed/plain/commit/4ebecd1856))
|
|
126
|
+
|
|
127
|
+
### Upgrade instructions
|
|
128
|
+
|
|
129
|
+
- No changes required
|
|
130
|
+
|
|
131
|
+
## [0.25.1](https://github.com/dropseed/plain/releases/plain-jobs@0.25.1) (2025-07-23)
|
|
132
|
+
|
|
133
|
+
### What's changed
|
|
134
|
+
|
|
135
|
+
- Added Bootstrap icons to admin interface for worker job views ([9e9f8b0](https://github.com/dropseed/plain/commit/9e9f8b0e2ccc3174f05034e6e908bb26345e1a5c))
|
|
136
|
+
- Removed the description field from admin views ([8d2352d](https://github.com/dropseed/plain/commit/8d2352db94277ddd87b6a480783c9f740b6e806f))
|
|
137
|
+
|
|
138
|
+
### Upgrade instructions
|
|
139
|
+
|
|
140
|
+
- No changes required
|
|
141
|
+
|
|
142
|
+
## [0.25.0](https://github.com/dropseed/plain/releases/plain-jobs@0.25.0) (2025-07-22)
|
|
143
|
+
|
|
144
|
+
### What's changed
|
|
145
|
+
|
|
146
|
+
- Removed `pk` alias and auto fields in favor of a single automatic `id` PrimaryKeyField ([4b8fa6a](https://github.com/dropseed/plain/commit/4b8fa6aef126a15e48b5f85e0652adf841eb7b5c))
|
|
147
|
+
- Admin interface methods now use `target_ids` parameter instead of `target_pks` for batch actions
|
|
148
|
+
- Model instance registry now uses `.id` instead of `.pk` for Global ID generation
|
|
149
|
+
- Updated database migrations to use `models.PrimaryKeyField()` instead of `models.BigAutoField()`
|
|
150
|
+
|
|
151
|
+
### Upgrade instructions
|
|
152
|
+
|
|
153
|
+
- No changes required
|
|
154
|
+
|
|
155
|
+
## [0.24.0](https://github.com/dropseed/plain/releases/plain-jobs@0.24.0) (2025-07-18)
|
|
156
|
+
|
|
157
|
+
### What's changed
|
|
158
|
+
|
|
159
|
+
- Added OpenTelemetry tracing support to job processing system ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0418))
|
|
160
|
+
- Job requests now capture trace context when queued from traced operations
|
|
161
|
+
- Job execution creates proper consumer spans linked to the original producer trace
|
|
162
|
+
- Added `trace_id` and `span_id` fields to JobRequest, Job, and JobResult models for trace correlation
|
|
163
|
+
|
|
164
|
+
### Upgrade instructions
|
|
165
|
+
|
|
166
|
+
- Run `plain migrate` to apply new database migration that adds trace context fields to worker tables
|
|
167
|
+
|
|
168
|
+
## [0.23.0](https://github.com/dropseed/plain/releases/plain-jobs@0.23.0) (2025-07-18)
|
|
169
|
+
|
|
170
|
+
### What's changed
|
|
171
|
+
|
|
172
|
+
- Migrations have been reset and consolidated into a single initial migration ([484f1b6e93](https://github.com/dropseed/plain/commit/484f1b6e93))
|
|
173
|
+
|
|
174
|
+
### Upgrade instructions
|
|
175
|
+
|
|
176
|
+
- Run `plain migrate --prune plainworker` to remove old migration records and apply the consolidated migration
|
|
177
|
+
|
|
178
|
+
## [0.22.5](https://github.com/dropseed/plain/releases/plain-jobs@0.22.5) (2025-06-24)
|
|
179
|
+
|
|
180
|
+
### What's changed
|
|
181
|
+
|
|
182
|
+
- No functional changes. This release only updates internal documentation (CHANGELOG) and contains no code modifications that impact users ([82710c3](https://github.com/dropseed/plain/commit/82710c3), [9a1963d](https://github.com/dropseed/plain/commit/9a1963d), [e1f5dd3](https://github.com/dropseed/plain/commit/e1f5dd3)).
|
|
183
|
+
|
|
184
|
+
### Upgrade instructions
|
|
185
|
+
|
|
186
|
+
- No changes required
|
plain/jobs/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# plain.jobs
|
|
2
|
+
|
|
3
|
+
**Process background jobs with a database-driven job queue.**
|
|
4
|
+
|
|
5
|
+
- [Overview](#overview)
|
|
6
|
+
- [Local development](#local-development)
|
|
7
|
+
- [Job parameters](#job-parameters)
|
|
8
|
+
- [Job methods](#job-methods)
|
|
9
|
+
- [Scheduled jobs](#scheduled-jobs)
|
|
10
|
+
- [Admin interface](#admin-interface)
|
|
11
|
+
- [Job history](#job-history)
|
|
12
|
+
- [Monitoring](#monitoring)
|
|
13
|
+
- [FAQs](#faqs)
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
Jobs are defined using the [`Job`](./jobs.py#Job) base class and the `run()` method at a minimum.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from plain.jobs import Job, register_job
|
|
22
|
+
from plain.email import send_mail
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register_job
|
|
26
|
+
class WelcomeUserJob(Job):
|
|
27
|
+
def __init__(self, user):
|
|
28
|
+
self.user = user
|
|
29
|
+
|
|
30
|
+
def run(self):
|
|
31
|
+
send_mail(
|
|
32
|
+
subject="Welcome!",
|
|
33
|
+
message=f"Hello from Plain, {self.user}",
|
|
34
|
+
from_email="welcome@plainframework.com",
|
|
35
|
+
recipient_list=[self.user.email],
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You can then create an instance of the job and call [`run_in_worker()`](./jobs.py#Job.run_in_worker) to enqueue it for a background worker to pick up.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
user = User.query.get(id=1)
|
|
43
|
+
WelcomeUserJob(user).run_in_worker()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Workers are run using the `plain jobs worker` command.
|
|
47
|
+
|
|
48
|
+
Jobs can be defined in any Python file, but it is suggested to use `app/jobs.py` or `app/{pkg}/jobs.py` as those will be imported automatically so the [`@register_job`](./registry.py#register_job) decorator will fire.
|
|
49
|
+
|
|
50
|
+
Run database migrations after installation:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
plain migrate
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Local development
|
|
57
|
+
|
|
58
|
+
In development, you will typically want to run the worker alongside your app. With [`plain.dev`](/plain-dev/plain/dev/README.md) you can do this by adding it to the `[tool.plain.dev.run]` section of your `pyproject.toml` file. Currently, you will need to use something like [watchfiles](https://pypi.org/project/watchfiles/) to add auto-reloading to the worker.
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
# pyproject.toml
|
|
62
|
+
[tool.plain.dev.run]
|
|
63
|
+
worker = {cmd = "watchfiles --filter python \"plain jobs worker --stats-every 0 --max-processes 2\" ."}
|
|
64
|
+
worker-slow = {cmd = "watchfiles --filter python \"plain jobs worker --queue slow --stats-every 0 --max-processes 2\" ."}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Job parameters
|
|
68
|
+
|
|
69
|
+
When calling `run_in_worker()`, you can specify several parameters to control job execution:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
job.run_in_worker(
|
|
73
|
+
queue="slow", # Target a specific queue (default: "default")
|
|
74
|
+
delay=60, # Delay in seconds (or timedelta/datetime)
|
|
75
|
+
priority=10, # Higher numbers run first (default: 0, use negatives for lower priority)
|
|
76
|
+
retries=3, # Number of retry attempts (default: 0)
|
|
77
|
+
unique_key="user-123-welcome", # Prevent duplicate jobs
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For more advanced parameter options, see [`Job.run_in_worker()`](./jobs.py#Job.run_in_worker).
|
|
82
|
+
|
|
83
|
+
## Job methods
|
|
84
|
+
|
|
85
|
+
The [`Job`](./jobs.py#Job) base class provides several methods you can override to customize behavior:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
class MyJob(Job):
|
|
89
|
+
def run(self):
|
|
90
|
+
# Required: The main job logic
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def get_queue(self) -> str:
|
|
94
|
+
# Specify the default queue for this job type
|
|
95
|
+
return "default"
|
|
96
|
+
|
|
97
|
+
def get_priority(self) -> int:
|
|
98
|
+
# Set the default priority
|
|
99
|
+
# Higher numbers run first: 10 > 5 > 0 > -5 > -10
|
|
100
|
+
# Use positive numbers for high priority, negative for low priority
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
def get_retries(self) -> int:
|
|
104
|
+
# Number of retry attempts on failure
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
def get_retry_delay(self, attempt: int) -> int:
|
|
108
|
+
# Delay in seconds before retry (attempt starts at 1)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
def get_unique_key(self) -> str:
|
|
112
|
+
# Return a key to prevent duplicate jobs
|
|
113
|
+
return ""
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Scheduled jobs
|
|
117
|
+
|
|
118
|
+
You can schedule jobs to run at specific times using the [`Schedule`](./scheduling.py#Schedule) class:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from plain.jobs import Job, register_job
|
|
122
|
+
from plain.jobs.scheduling import Schedule
|
|
123
|
+
|
|
124
|
+
@register_job
|
|
125
|
+
class DailyReportJob(Job):
|
|
126
|
+
schedule = Schedule.from_cron("0 9 * * *") # Every day at 9 AM
|
|
127
|
+
|
|
128
|
+
def run(self):
|
|
129
|
+
# Generate daily report
|
|
130
|
+
pass
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `Schedule` class supports standard cron syntax and special strings:
|
|
134
|
+
|
|
135
|
+
- `@yearly` or `@annually` - Run once a year
|
|
136
|
+
- `@monthly` - Run once a month
|
|
137
|
+
- `@weekly` - Run once a week
|
|
138
|
+
- `@daily` or `@midnight` - Run once a day
|
|
139
|
+
- `@hourly` - Run once an hour
|
|
140
|
+
|
|
141
|
+
For custom schedules, see [`Schedule`](./scheduling.py#Schedule).
|
|
142
|
+
|
|
143
|
+
## Admin interface
|
|
144
|
+
|
|
145
|
+
The jobs package includes admin views for monitoring jobs under the "Jobs" section. The admin interface provides:
|
|
146
|
+
|
|
147
|
+
- **Requests**: View pending jobs in the queue
|
|
148
|
+
- **Processes**: Monitor currently running jobs
|
|
149
|
+
- **Results**: Review completed and failed job history
|
|
150
|
+
|
|
151
|
+
Dashboard cards show at-a-glance statistics for successful, errored, lost, and retried jobs.
|
|
152
|
+
|
|
153
|
+
## Job history
|
|
154
|
+
|
|
155
|
+
Job execution history is stored in the [`JobResult`](./models.py#JobResult) model. This includes:
|
|
156
|
+
|
|
157
|
+
- Job class and parameters
|
|
158
|
+
- Start and end times
|
|
159
|
+
- Success/failure status
|
|
160
|
+
- Error messages and tracebacks for failed jobs
|
|
161
|
+
- Worker information
|
|
162
|
+
|
|
163
|
+
History retention is controlled by the `JOBS_RESULTS_RETENTION` setting (defaults to 7 days):
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# app/settings.py
|
|
167
|
+
JOBS_RESULTS_RETENTION = 60 * 60 * 24 * 30 # 30 days (in seconds)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Job timeout can be configured with `JOBS_TIMEOUT` (defaults to 1 day):
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
# app/settings.py
|
|
174
|
+
JOBS_TIMEOUT = 60 * 60 * 24 # 1 day (in seconds)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Monitoring
|
|
178
|
+
|
|
179
|
+
Workers report statistics and can be monitored using the `--stats-every` option:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Report stats every 60 seconds
|
|
183
|
+
plain jobs worker --stats-every 60
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The worker integrates with OpenTelemetry for distributed tracing. Spans are created for:
|
|
187
|
+
|
|
188
|
+
- Job scheduling (`run_in_worker`)
|
|
189
|
+
- Job execution
|
|
190
|
+
- Job completion/failure
|
|
191
|
+
|
|
192
|
+
Jobs can be linked to the originating trace context, allowing you to track jobs initiated from web requests.
|
|
193
|
+
|
|
194
|
+
## FAQs
|
|
195
|
+
|
|
196
|
+
#### How do I ensure a job only runs once?
|
|
197
|
+
|
|
198
|
+
Return a unique key from the `get_unique_key()` method:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
class ProcessUserDataJob(Job):
|
|
202
|
+
def __init__(self, user_id):
|
|
203
|
+
self.user_id = user_id
|
|
204
|
+
|
|
205
|
+
def get_unique_key(self):
|
|
206
|
+
return f"process-user-{self.user_id}"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Can I run multiple workers?
|
|
210
|
+
|
|
211
|
+
Yes, you can run multiple worker processes:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
plain jobs worker --max-processes 4
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Or run workers for specific queues:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
plain jobs worker --queue slow --max-processes 2
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### How do I handle job failures?
|
|
224
|
+
|
|
225
|
+
Set the number of retries and implement retry delays:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
class MyJob(Job):
|
|
229
|
+
def get_retries(self):
|
|
230
|
+
return 3
|
|
231
|
+
|
|
232
|
+
def get_retry_delay(self, attempt):
|
|
233
|
+
# Exponential backoff: 1s, 2s, 4s
|
|
234
|
+
return 2 ** (attempt - 1)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Installation
|
|
238
|
+
|
|
239
|
+
Install the `plain.jobs` package from [PyPI](https://pypi.org/project/plain.jobs/):
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
uv add plain.jobs
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Add to your `INSTALLED_PACKAGES`:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
# app/settings.py
|
|
249
|
+
INSTALLED_PACKAGES = [
|
|
250
|
+
...
|
|
251
|
+
"plain.jobs",
|
|
252
|
+
]
|
|
253
|
+
```
|
plain/jobs/__init__.py
ADDED
plain/jobs/admin.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from plain import models
|
|
7
|
+
from plain.admin.cards import Card
|
|
8
|
+
from plain.admin.views import (
|
|
9
|
+
AdminModelDetailView,
|
|
10
|
+
AdminModelListView,
|
|
11
|
+
AdminViewset,
|
|
12
|
+
register_viewset,
|
|
13
|
+
)
|
|
14
|
+
from plain.http import ResponseRedirect
|
|
15
|
+
from plain.runtime import settings
|
|
16
|
+
|
|
17
|
+
from .models import JobProcess, JobRequest, JobResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _td_format(td_object: timedelta) -> str:
|
|
21
|
+
seconds = int(td_object.total_seconds())
|
|
22
|
+
periods = [
|
|
23
|
+
("year", 60 * 60 * 24 * 365),
|
|
24
|
+
("month", 60 * 60 * 24 * 30),
|
|
25
|
+
("day", 60 * 60 * 24),
|
|
26
|
+
("hour", 60 * 60),
|
|
27
|
+
("minute", 60),
|
|
28
|
+
("second", 1),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
strings = []
|
|
32
|
+
for period_name, period_seconds in periods:
|
|
33
|
+
if seconds > period_seconds:
|
|
34
|
+
period_value, seconds = divmod(seconds, period_seconds)
|
|
35
|
+
has_s = "s" if period_value > 1 else ""
|
|
36
|
+
strings.append(f"{period_value} {period_name}{has_s}")
|
|
37
|
+
|
|
38
|
+
return ", ".join(strings)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SuccessfulJobsCard(Card):
|
|
42
|
+
title = "Successful"
|
|
43
|
+
text = "View"
|
|
44
|
+
|
|
45
|
+
def get_number(self) -> int:
|
|
46
|
+
return JobResult.query.successful().count()
|
|
47
|
+
|
|
48
|
+
def get_link(self) -> str:
|
|
49
|
+
return JobResultViewset.ListView.get_view_url() + "?display=Successful"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ErroredJobsCard(Card):
|
|
53
|
+
title = "Errored"
|
|
54
|
+
text = "View"
|
|
55
|
+
|
|
56
|
+
def get_number(self) -> int:
|
|
57
|
+
return JobResult.query.errored().count()
|
|
58
|
+
|
|
59
|
+
def get_link(self) -> str:
|
|
60
|
+
return JobResultViewset.ListView.get_view_url() + "?display=Errored"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LostJobsCard(Card):
|
|
64
|
+
title = "Lost"
|
|
65
|
+
text = "View" # TODO make not required - just an icon?
|
|
66
|
+
|
|
67
|
+
def get_description(self) -> str:
|
|
68
|
+
delta = timedelta(seconds=settings.JOBS_TIMEOUT)
|
|
69
|
+
return f"Jobs are considered lost after {_td_format(delta)}"
|
|
70
|
+
|
|
71
|
+
def get_number(self) -> int:
|
|
72
|
+
return JobResult.query.lost().count()
|
|
73
|
+
|
|
74
|
+
def get_link(self) -> str:
|
|
75
|
+
return JobResultViewset.ListView.get_view_url() + "?display=Lost"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RetriedJobsCard(Card):
|
|
79
|
+
title = "Retried"
|
|
80
|
+
text = "View" # TODO make not required - just an icon?
|
|
81
|
+
|
|
82
|
+
def get_number(self) -> int:
|
|
83
|
+
return JobResult.query.retried().count()
|
|
84
|
+
|
|
85
|
+
def get_link(self) -> str:
|
|
86
|
+
return JobResultViewset.ListView.get_view_url() + "?display=Retried"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WaitingJobsCard(Card):
|
|
90
|
+
title = "Waiting"
|
|
91
|
+
|
|
92
|
+
def get_number(self) -> int:
|
|
93
|
+
return JobProcess.query.waiting().count()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RunningJobsCard(Card):
|
|
97
|
+
title = "Running"
|
|
98
|
+
|
|
99
|
+
def get_number(self) -> int:
|
|
100
|
+
return JobProcess.query.running().count()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register_viewset
|
|
104
|
+
class JobRequestViewset(AdminViewset):
|
|
105
|
+
class ListView(AdminModelListView):
|
|
106
|
+
nav_section = "Jobs"
|
|
107
|
+
nav_icon = "gear"
|
|
108
|
+
model = JobRequest
|
|
109
|
+
title = "Requests"
|
|
110
|
+
fields = ["id", "job_class", "priority", "created_at", "start_at", "unique_key"]
|
|
111
|
+
actions = ["Delete"]
|
|
112
|
+
|
|
113
|
+
def perform_action(self, action: str, target_ids: list[int]) -> None:
|
|
114
|
+
if action == "Delete":
|
|
115
|
+
JobRequest.query.filter(id__in=target_ids).delete()
|
|
116
|
+
|
|
117
|
+
class DetailView(AdminModelDetailView):
|
|
118
|
+
model = JobRequest
|
|
119
|
+
title = "Request"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@register_viewset
|
|
123
|
+
class JobProcessViewset(AdminViewset):
|
|
124
|
+
class ListView(AdminModelListView):
|
|
125
|
+
nav_section = "Jobs"
|
|
126
|
+
nav_icon = "gear"
|
|
127
|
+
model = JobProcess
|
|
128
|
+
title = "Processes"
|
|
129
|
+
fields = [
|
|
130
|
+
"id",
|
|
131
|
+
"job_class",
|
|
132
|
+
"priority",
|
|
133
|
+
"created_at",
|
|
134
|
+
"started_at",
|
|
135
|
+
"unique_key",
|
|
136
|
+
]
|
|
137
|
+
actions = ["Delete"]
|
|
138
|
+
cards = [
|
|
139
|
+
WaitingJobsCard,
|
|
140
|
+
RunningJobsCard,
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
def perform_action(self, action: str, target_ids: list[int]) -> None:
|
|
144
|
+
if action == "Delete":
|
|
145
|
+
JobProcess.query.filter(id__in=target_ids).delete()
|
|
146
|
+
|
|
147
|
+
class DetailView(AdminModelDetailView):
|
|
148
|
+
model = JobProcess
|
|
149
|
+
title = "Process"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register_viewset
|
|
153
|
+
class JobResultViewset(AdminViewset):
|
|
154
|
+
class ListView(AdminModelListView):
|
|
155
|
+
nav_section = "Jobs"
|
|
156
|
+
nav_icon = "gear"
|
|
157
|
+
model = JobResult
|
|
158
|
+
title = "Results"
|
|
159
|
+
fields = [
|
|
160
|
+
"id",
|
|
161
|
+
"job_class",
|
|
162
|
+
"priority",
|
|
163
|
+
"created_at",
|
|
164
|
+
"status",
|
|
165
|
+
"retried",
|
|
166
|
+
"is_retry",
|
|
167
|
+
]
|
|
168
|
+
search_fields = [
|
|
169
|
+
"uuid",
|
|
170
|
+
"job_process_uuid",
|
|
171
|
+
"job_request_uuid",
|
|
172
|
+
"job_class",
|
|
173
|
+
]
|
|
174
|
+
cards = [
|
|
175
|
+
SuccessfulJobsCard,
|
|
176
|
+
ErroredJobsCard,
|
|
177
|
+
LostJobsCard,
|
|
178
|
+
RetriedJobsCard,
|
|
179
|
+
]
|
|
180
|
+
filters = [
|
|
181
|
+
"Successful",
|
|
182
|
+
"Errored",
|
|
183
|
+
"Cancelled",
|
|
184
|
+
"Lost",
|
|
185
|
+
"Retried",
|
|
186
|
+
]
|
|
187
|
+
actions = [
|
|
188
|
+
"Retry",
|
|
189
|
+
]
|
|
190
|
+
allow_global_search = False
|
|
191
|
+
|
|
192
|
+
def get_initial_queryset(self) -> Any:
|
|
193
|
+
queryset = super().get_initial_queryset()
|
|
194
|
+
queryset = queryset.annotate(
|
|
195
|
+
retried=models.Case(
|
|
196
|
+
models.When(retry_job_request_uuid__isnull=False, then=True),
|
|
197
|
+
default=False,
|
|
198
|
+
output_field=models.BooleanField(),
|
|
199
|
+
),
|
|
200
|
+
is_retry=models.Case(
|
|
201
|
+
models.When(retry_attempt__gt=0, then=True),
|
|
202
|
+
default=False,
|
|
203
|
+
output_field=models.BooleanField(),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
if self.display == "Successful":
|
|
207
|
+
return queryset.successful()
|
|
208
|
+
if self.display == "Errored":
|
|
209
|
+
return queryset.errored()
|
|
210
|
+
if self.display == "Cancelled":
|
|
211
|
+
return queryset.cancelled()
|
|
212
|
+
if self.display == "Lost":
|
|
213
|
+
return queryset.lost()
|
|
214
|
+
if self.display == "Retried":
|
|
215
|
+
return queryset.retried()
|
|
216
|
+
return queryset
|
|
217
|
+
|
|
218
|
+
def get_fields(self) -> list[str]:
|
|
219
|
+
fields = super().get_fields()
|
|
220
|
+
if self.display == "Retried":
|
|
221
|
+
fields.append("retries")
|
|
222
|
+
fields.append("retry_attempt")
|
|
223
|
+
return fields
|
|
224
|
+
|
|
225
|
+
def perform_action(self, action: str, target_ids: list[int]) -> None:
|
|
226
|
+
if action == "Retry":
|
|
227
|
+
for result in JobResult.query.filter(id__in=target_ids):
|
|
228
|
+
result.retry_job(delay=0)
|
|
229
|
+
else:
|
|
230
|
+
raise ValueError("Invalid action")
|
|
231
|
+
|
|
232
|
+
class DetailView(AdminModelDetailView):
|
|
233
|
+
model = JobResult
|
|
234
|
+
title = "Result"
|
|
235
|
+
|
|
236
|
+
def post(self) -> ResponseRedirect:
|
|
237
|
+
self.object.retry_job(delay=0)
|
|
238
|
+
return ResponseRedirect(".")
|
plain/jobs/chores.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from plain.chores import register_chore
|
|
4
|
+
from plain.runtime import settings
|
|
5
|
+
from plain.utils import timezone
|
|
6
|
+
|
|
7
|
+
from .models import JobResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register_chore("jobs")
|
|
11
|
+
def clear_completed() -> str:
|
|
12
|
+
"""Delete all completed job results in all queues."""
|
|
13
|
+
cutoff = timezone.now() - datetime.timedelta(
|
|
14
|
+
seconds=settings.JOBS_RESULTS_RETENTION
|
|
15
|
+
)
|
|
16
|
+
results = JobResult.query.filter(created_at__lt=cutoff).delete()
|
|
17
|
+
return f"{results[0]} jobs deleted"
|