django-maintenance-tasks 0.1.0__tar.gz

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.
Files changed (36) hide show
  1. django_maintenance_tasks-0.1.0/.github/workflows/publish.yml +44 -0
  2. django_maintenance_tasks-0.1.0/.gitignore +7 -0
  3. django_maintenance_tasks-0.1.0/LICENSE +21 -0
  4. django_maintenance_tasks-0.1.0/PKG-INFO +176 -0
  5. django_maintenance_tasks-0.1.0/README.md +158 -0
  6. django_maintenance_tasks-0.1.0/example/__init__.py +0 -0
  7. django_maintenance_tasks-0.1.0/example/blog/__init__.py +0 -0
  8. django_maintenance_tasks-0.1.0/example/blog/maintenance_tasks.py +35 -0
  9. django_maintenance_tasks-0.1.0/example/blog/migrations/0001_initial.py +22 -0
  10. django_maintenance_tasks-0.1.0/example/blog/migrations/__init__.py +0 -0
  11. django_maintenance_tasks-0.1.0/example/blog/models.py +6 -0
  12. django_maintenance_tasks-0.1.0/example/e2e.py +270 -0
  13. django_maintenance_tasks-0.1.0/example/manage.py +11 -0
  14. django_maintenance_tasks-0.1.0/example/settings.py +66 -0
  15. django_maintenance_tasks-0.1.0/example/urls.py +4 -0
  16. django_maintenance_tasks-0.1.0/pyproject.toml +32 -0
  17. django_maintenance_tasks-0.1.0/src/maintenance_tasks/__init__.py +3 -0
  18. django_maintenance_tasks-0.1.0/src/maintenance_tasks/admin.py +125 -0
  19. django_maintenance_tasks-0.1.0/src/maintenance_tasks/apps.py +13 -0
  20. django_maintenance_tasks-0.1.0/src/maintenance_tasks/management/__init__.py +0 -0
  21. django_maintenance_tasks-0.1.0/src/maintenance_tasks/management/commands/__init__.py +0 -0
  22. django_maintenance_tasks-0.1.0/src/maintenance_tasks/management/commands/maintenance_tasks.py +50 -0
  23. django_maintenance_tasks-0.1.0/src/maintenance_tasks/migrations/0001_initial.py +39 -0
  24. django_maintenance_tasks-0.1.0/src/maintenance_tasks/migrations/__init__.py +0 -0
  25. django_maintenance_tasks-0.1.0/src/maintenance_tasks/models.py +78 -0
  26. django_maintenance_tasks-0.1.0/src/maintenance_tasks/runner.py +266 -0
  27. django_maintenance_tasks-0.1.0/src/maintenance_tasks/task.py +72 -0
  28. django_maintenance_tasks-0.1.0/src/maintenance_tasks/templates/admin/maintenance_tasks/run/change_list.html +6 -0
  29. django_maintenance_tasks-0.1.0/src/maintenance_tasks/templates/admin/maintenance_tasks/run/start.html +20 -0
  30. django_maintenance_tasks-0.1.0/tests/__init__.py +0 -0
  31. django_maintenance_tasks-0.1.0/tests/settings.py +38 -0
  32. django_maintenance_tasks-0.1.0/tests/test_runner.py +265 -0
  33. django_maintenance_tasks-0.1.0/tests/testapp/__init__.py +0 -0
  34. django_maintenance_tasks-0.1.0/tests/testapp/maintenance_tasks.py +73 -0
  35. django_maintenance_tasks-0.1.0/tests/testapp/models.py +6 -0
  36. django_maintenance_tasks-0.1.0/tests/urls.py +4 -0
@@ -0,0 +1,44 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.13"
15
+ - run: pip install -e ".[dev]"
16
+ - run: pytest
17
+
18
+ build:
19
+ needs: test
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.13"
26
+ - run: pip install build
27
+ - run: python -m build
28
+ - uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ publish:
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment: pypi
37
+ permissions:
38
+ id-token: write # trusted publishing, no API token needed
39
+ steps:
40
+ - uses: actions/download-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ajinkya Pisal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-maintenance-tasks
3
+ Version: 0.1.0
4
+ Summary: Pausable, resumable, throttled data backfills for Django, built on django.tasks.
5
+ Project-URL: Homepage, https://github.com/ajinkyapisal/django-maintenance-tasks
6
+ Project-URL: Issues, https://github.com/ajinkyapisal/django-maintenance-tasks/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: Django :: 6.0
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: django>=6.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Requires-Dist: pytest-django; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # django-maintenance-tasks
20
+
21
+ Pausable, resumable, throttled data backfills for Django, inspired by Shopify's
22
+ [`maintenance_tasks`](https://github.com/Shopify/maintenance_tasks) for Rails.
23
+
24
+ Backfilling millions of rows from a management command or a `RunPython`
25
+ migration is risky: it can lock tables, slow the site down, and if it crashes
26
+ halfway it starts again from zero. This package turns each backfill into a small
27
+ class that runs in the background in batches, saves its position as it goes, and
28
+ can be started, paused, resumed and cancelled from the Django admin.
29
+
30
+ It runs on Django's built-in [Tasks framework](https://docs.djangoproject.com/en/stable/topics/tasks/),
31
+ so it works with any task backend: the database backend from `django-tasks`, RQ,
32
+ Celery and others.
33
+
34
+ > Status: early scaffold. The API may change.
35
+
36
+ ## Install
37
+
38
+ Requires Python 3.12+ and Django 6.0+. Not on PyPI yet, so install from GitHub:
39
+
40
+ ```bash
41
+ pip install git+https://github.com/ajinkyapisal/django-maintenance-tasks
42
+ ```
43
+
44
+ ```python
45
+ INSTALLED_APPS = [
46
+ # ...
47
+ "maintenance_tasks",
48
+ ]
49
+
50
+ # Any django.tasks backend that runs tasks in a worker.
51
+ TASKS = {"default": {"BACKEND": "..."}}
52
+ ```
53
+
54
+ ```bash
55
+ python manage.py migrate
56
+ ```
57
+
58
+ ## Write a task
59
+
60
+ Put tasks in `<app>/maintenance_tasks.py`. They are discovered automatically.
61
+
62
+ ```python
63
+ from django.utils.text import slugify
64
+
65
+ from maintenance_tasks import MaintenanceTask
66
+ from blog.models import Post
67
+
68
+
69
+ class BackfillSlugs(MaintenanceTask):
70
+ description = "Fill in missing post slugs."
71
+ batch_size = 500
72
+
73
+ def collection(self):
74
+ return Post.objects.filter(slug="")
75
+
76
+ def process(self, post):
77
+ post.slug = slugify(post.title)
78
+ post.save(update_fields=["slug"])
79
+ ```
80
+
81
+ - `collection()` returns a QuerySet, a list or another iterable. QuerySets are
82
+ walked in primary key order. Other iterables are walked by position, so they
83
+ must return the same items in the same order every time.
84
+ - `process(item)` handles one item. It must be safe to run twice on the same
85
+ item: after a crash, up to one batch can be processed again.
86
+ - `self.arguments` holds the JSON arguments the run was started with.
87
+ - `count()` gives the total for the progress bar. Override it if counting is
88
+ slow.
89
+ - `throttle()` returns `True` to back off for `throttle_backoff` seconds, for
90
+ example when database replicas are lagging.
91
+
92
+ ## Run it
93
+
94
+ From the admin: **Maintenance tasks → Runs → Start a run**. Select runs to
95
+ pause, resume or cancel them. Starting and controlling runs needs the
96
+ `maintenance_tasks.add_run` permission.
97
+
98
+ From the command line:
99
+
100
+ ```bash
101
+ python manage.py maintenance_tasks list
102
+ python manage.py maintenance_tasks run blog.maintenance_tasks.BackfillSlugs --arguments '{"batch": "2024-q1"}'
103
+ ```
104
+
105
+ From code:
106
+
107
+ ```python
108
+ from maintenance_tasks import runner
109
+
110
+ run = runner.start("blog.maintenance_tasks.BackfillSlugs")
111
+ runner.pause(run)
112
+ runner.resume(run)
113
+ runner.cancel(run)
114
+ ```
115
+
116
+ ## How it works
117
+
118
+ - Each run is a `Run` row with a status, a cursor and progress counts. Only one
119
+ active run per task is allowed.
120
+ - A run is processed by a `django.tasks` job. After every batch it saves the
121
+ cursor and checks whether a pause or cancel was requested.
122
+ - A job gives up its worker after `MAX_RUNTIME` seconds (default 120) and
123
+ enqueues the next job, so long backfills never block a worker for hours:
124
+
125
+ ```python
126
+ MAINTENANCE_TASKS = {"MAX_RUNTIME": 120, "STALE_AFTER": 600}
127
+ ```
128
+
129
+ - If a worker dies mid-run (killed, out of memory, machine lost), the run stays
130
+ `running` with an old heartbeat. `recover_stalled()` puts it back on the
131
+ queue from its last checkpoint. Run it from cron, or use the admin action:
132
+
133
+ ```bash
134
+ python manage.py maintenance_tasks recover
135
+ ```
136
+
137
+ A run counts as stalled after `STALE_AFTER` seconds without a heartbeat
138
+ (default 600). Keep it well above the time one batch takes.
139
+ - Every job carries a generation number. Recovering a run starts a new
140
+ generation, so if the old worker was only frozen and comes back, it stops at
141
+ its next checkpoint instead of running alongside the new one. Duplicate
142
+ deliveries of the same job are ignored too.
143
+ - If `process()` raises, the run is marked `errored` with the exception and
144
+ traceback, and the cursor points at the last item that succeeded.
145
+ - Throttle backoff uses `run_after` when the backend supports deferred tasks.
146
+ Otherwise the worker sleeps for the backoff time.
147
+
148
+ ## Roadmap
149
+
150
+ - CSV upload as a collection
151
+ - Typed task parameters with a generated admin form
152
+ - Dry runs
153
+ - Retrying errored runs from the cursor
154
+ - Live progress in the admin
155
+ - Run history: who started what, with which arguments
156
+
157
+ ## Development
158
+
159
+ ```bash
160
+ python3.13 -m venv .venv
161
+ .venv/bin/pip install -e ".[dev]"
162
+ .venv/bin/pytest
163
+ ```
164
+
165
+ `example/` is a demo project on Postgres with the `django-tasks-db` backend.
166
+ `example/e2e.py` runs real `db_worker` processes and checks pausing,
167
+ cancelling, throttling, errors, graceful shutdown, killed workers and frozen
168
+ workers:
169
+
170
+ ```bash
171
+ docker run -d --name dmt-pg -e POSTGRES_HOST_AUTH_METHOD=trust \
172
+ -e POSTGRES_DB=dmt_demo -p 55432:5432 postgres:17-alpine
173
+ .venv/bin/pip install django-tasks-db "psycopg[binary]"
174
+ .venv/bin/python example/manage.py migrate
175
+ .venv/bin/python example/e2e.py
176
+ ```
@@ -0,0 +1,158 @@
1
+ # django-maintenance-tasks
2
+
3
+ Pausable, resumable, throttled data backfills for Django, inspired by Shopify's
4
+ [`maintenance_tasks`](https://github.com/Shopify/maintenance_tasks) for Rails.
5
+
6
+ Backfilling millions of rows from a management command or a `RunPython`
7
+ migration is risky: it can lock tables, slow the site down, and if it crashes
8
+ halfway it starts again from zero. This package turns each backfill into a small
9
+ class that runs in the background in batches, saves its position as it goes, and
10
+ can be started, paused, resumed and cancelled from the Django admin.
11
+
12
+ It runs on Django's built-in [Tasks framework](https://docs.djangoproject.com/en/stable/topics/tasks/),
13
+ so it works with any task backend: the database backend from `django-tasks`, RQ,
14
+ Celery and others.
15
+
16
+ > Status: early scaffold. The API may change.
17
+
18
+ ## Install
19
+
20
+ Requires Python 3.12+ and Django 6.0+. Not on PyPI yet, so install from GitHub:
21
+
22
+ ```bash
23
+ pip install git+https://github.com/ajinkyapisal/django-maintenance-tasks
24
+ ```
25
+
26
+ ```python
27
+ INSTALLED_APPS = [
28
+ # ...
29
+ "maintenance_tasks",
30
+ ]
31
+
32
+ # Any django.tasks backend that runs tasks in a worker.
33
+ TASKS = {"default": {"BACKEND": "..."}}
34
+ ```
35
+
36
+ ```bash
37
+ python manage.py migrate
38
+ ```
39
+
40
+ ## Write a task
41
+
42
+ Put tasks in `<app>/maintenance_tasks.py`. They are discovered automatically.
43
+
44
+ ```python
45
+ from django.utils.text import slugify
46
+
47
+ from maintenance_tasks import MaintenanceTask
48
+ from blog.models import Post
49
+
50
+
51
+ class BackfillSlugs(MaintenanceTask):
52
+ description = "Fill in missing post slugs."
53
+ batch_size = 500
54
+
55
+ def collection(self):
56
+ return Post.objects.filter(slug="")
57
+
58
+ def process(self, post):
59
+ post.slug = slugify(post.title)
60
+ post.save(update_fields=["slug"])
61
+ ```
62
+
63
+ - `collection()` returns a QuerySet, a list or another iterable. QuerySets are
64
+ walked in primary key order. Other iterables are walked by position, so they
65
+ must return the same items in the same order every time.
66
+ - `process(item)` handles one item. It must be safe to run twice on the same
67
+ item: after a crash, up to one batch can be processed again.
68
+ - `self.arguments` holds the JSON arguments the run was started with.
69
+ - `count()` gives the total for the progress bar. Override it if counting is
70
+ slow.
71
+ - `throttle()` returns `True` to back off for `throttle_backoff` seconds, for
72
+ example when database replicas are lagging.
73
+
74
+ ## Run it
75
+
76
+ From the admin: **Maintenance tasks → Runs → Start a run**. Select runs to
77
+ pause, resume or cancel them. Starting and controlling runs needs the
78
+ `maintenance_tasks.add_run` permission.
79
+
80
+ From the command line:
81
+
82
+ ```bash
83
+ python manage.py maintenance_tasks list
84
+ python manage.py maintenance_tasks run blog.maintenance_tasks.BackfillSlugs --arguments '{"batch": "2024-q1"}'
85
+ ```
86
+
87
+ From code:
88
+
89
+ ```python
90
+ from maintenance_tasks import runner
91
+
92
+ run = runner.start("blog.maintenance_tasks.BackfillSlugs")
93
+ runner.pause(run)
94
+ runner.resume(run)
95
+ runner.cancel(run)
96
+ ```
97
+
98
+ ## How it works
99
+
100
+ - Each run is a `Run` row with a status, a cursor and progress counts. Only one
101
+ active run per task is allowed.
102
+ - A run is processed by a `django.tasks` job. After every batch it saves the
103
+ cursor and checks whether a pause or cancel was requested.
104
+ - A job gives up its worker after `MAX_RUNTIME` seconds (default 120) and
105
+ enqueues the next job, so long backfills never block a worker for hours:
106
+
107
+ ```python
108
+ MAINTENANCE_TASKS = {"MAX_RUNTIME": 120, "STALE_AFTER": 600}
109
+ ```
110
+
111
+ - If a worker dies mid-run (killed, out of memory, machine lost), the run stays
112
+ `running` with an old heartbeat. `recover_stalled()` puts it back on the
113
+ queue from its last checkpoint. Run it from cron, or use the admin action:
114
+
115
+ ```bash
116
+ python manage.py maintenance_tasks recover
117
+ ```
118
+
119
+ A run counts as stalled after `STALE_AFTER` seconds without a heartbeat
120
+ (default 600). Keep it well above the time one batch takes.
121
+ - Every job carries a generation number. Recovering a run starts a new
122
+ generation, so if the old worker was only frozen and comes back, it stops at
123
+ its next checkpoint instead of running alongside the new one. Duplicate
124
+ deliveries of the same job are ignored too.
125
+ - If `process()` raises, the run is marked `errored` with the exception and
126
+ traceback, and the cursor points at the last item that succeeded.
127
+ - Throttle backoff uses `run_after` when the backend supports deferred tasks.
128
+ Otherwise the worker sleeps for the backoff time.
129
+
130
+ ## Roadmap
131
+
132
+ - CSV upload as a collection
133
+ - Typed task parameters with a generated admin form
134
+ - Dry runs
135
+ - Retrying errored runs from the cursor
136
+ - Live progress in the admin
137
+ - Run history: who started what, with which arguments
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ python3.13 -m venv .venv
143
+ .venv/bin/pip install -e ".[dev]"
144
+ .venv/bin/pytest
145
+ ```
146
+
147
+ `example/` is a demo project on Postgres with the `django-tasks-db` backend.
148
+ `example/e2e.py` runs real `db_worker` processes and checks pausing,
149
+ cancelling, throttling, errors, graceful shutdown, killed workers and frozen
150
+ workers:
151
+
152
+ ```bash
153
+ docker run -d --name dmt-pg -e POSTGRES_HOST_AUTH_METHOD=trust \
154
+ -e POSTGRES_DB=dmt_demo -p 55432:5432 postgres:17-alpine
155
+ .venv/bin/pip install django-tasks-db "psycopg[binary]"
156
+ .venv/bin/python example/manage.py migrate
157
+ .venv/bin/python example/e2e.py
158
+ ```
File without changes
@@ -0,0 +1,35 @@
1
+ import time
2
+ from pathlib import Path
3
+
4
+ from django.utils.text import slugify
5
+
6
+ from example.blog.models import Post
7
+ from maintenance_tasks import MaintenanceTask
8
+
9
+
10
+ class BackfillSlugs(MaintenanceTask):
11
+ """Fills in missing slugs.
12
+
13
+ Arguments:
14
+ delay: seconds to sleep per post, to make the run slow enough to watch.
15
+ throttle_file: while this file exists, the task backs off.
16
+ fail_at: raise when processing the post with this title.
17
+ """
18
+
19
+ description = "Fill in missing post slugs."
20
+ batch_size = 100
21
+ throttle_backoff = 2
22
+
23
+ def collection(self):
24
+ return Post.objects.filter(slug="")
25
+
26
+ def process(self, post):
27
+ if post.title == self.arguments.get("fail_at"):
28
+ raise ValueError(f"cannot slugify {post.title!r}")
29
+ time.sleep(self.arguments.get("delay", 0))
30
+ post.slug = slugify(post.title)
31
+ post.save(update_fields=["slug"])
32
+
33
+ def throttle(self):
34
+ path = self.arguments.get("throttle_file")
35
+ return bool(path) and Path(path).exists()
@@ -0,0 +1,22 @@
1
+ # Generated by Django 6.1.1 on 2026-10-03 08:21
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = [
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='Post',
16
+ fields=[
17
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+ ('title', models.CharField(max_length=200)),
19
+ ('slug', models.SlugField(blank=True)),
20
+ ],
21
+ ),
22
+ ]
@@ -0,0 +1,6 @@
1
+ from django.db import models
2
+
3
+
4
+ class Post(models.Model):
5
+ title = models.CharField(max_length=200)
6
+ slug = models.SlugField(blank=True)