47 Commits

Author SHA1 Message Date
Stan
2c456cd0aa Release v1.7.2 2024-09-10 00:16:18 +02:00
Stan Triepels
8f54e5d5ae Add twine check and fix readme (#216)
* Add twine check

* add poetry to install

* move up remove black/flake formatting

* fix path

* fix?

* fix rst?

* add rstcheck

* test all files?

* all

* remove complaining image

* only check readme

* remove not needed dependencies
2024-09-10 00:10:14 +02:00
Stan
689c881178 Release v1.7.1 2024-09-08 04:20:13 +02:00
Stan
923c5daa73 Release v1.7.0 2024-09-08 03:52:37 +02:00
Stan Triepels
69e794f1af Add index on succeeded tasks (#164)
* Add index on task table

* also add models file

* add make commands and limit index

* format
2024-09-08 03:37:28 +02:00
Stan Triepels
2191897825 Add example project (#215)
* add example project

* format

* fix format
2024-09-08 03:13:49 +02:00
Andrii Yurchuk
af505e8d70 Call mark_process_dead on worker pid if prometheus_client is installed (#212) 2024-09-03 22:29:00 +02:00
kimihito
48123af09d chore: Add Support Django 5.1 (#207) 2024-08-13 14:14:40 +02:00
kimihito
faaa57be93 chore: Update CI for Django 4.2 Python 3.12 support (#208) 2024-08-13 14:14:11 +02:00
jv3ga
5015d8ee4d fix: Oracle ORM backend compatibility #180 (#186) 2024-08-13 04:40:54 +02:00
Stan Triepels
cb253357fb Fix repeating task after timeout (#184) 2024-06-23 22:36:29 +02:00
GDay
efa0d6d9e4 ignore fuff formating 2024-05-11 03:06:58 +02:00
Stan Triepels
ad4d24e17c Replace black/isort with ruff (#188)
* Replace black/isort with ruff

* fix typo
2024-05-11 03:04:32 +02:00
Stan Triepels
1d1d0a9aa5 Fix max attempts for value 1 (#185) 2024-05-06 17:28:27 +02:00
Stan Triepels
5e4aa65f62 Remove support for Django 3.2 and 4.1 (#183) 2024-05-05 00:42:09 +02:00
GDay
7add68f770 Release v1.6.2 2024-03-05 16:46:08 +01:00
Stan Triepels
782b1de60d Allow different broker on chain (#156) 2024-03-02 22:45:40 +01:00
Josh Thomas
88430c9014 Fix formatting issues in README.rst (#159) 2024-02-20 18:04:46 +01:00
Stan Triepels
6265a54008 Update docs to add cluster option to the async_task (#157) 2024-02-18 04:00:07 +01:00
Stan Triepels
1b638e5802 Update release/test dependencies (#147)
* Update release dependencies

* Update test.yml
2023-12-14 16:06:34 +01:00
Tobias Menzel
13a382cca2 Fix for Negative Repeat Count in Scheduler (#101) (#146)
* Comment out decrement of s.repeats in scheduler to fix negative repeat count issue

* Fix repeat count handling in scheduler

- Reset `repeats` to -1 for values less than -1
- Decrement `repeats` by 1 if it's greater than 0
2023-12-14 14:44:07 +01:00
Stan Triepels
098aa66a2d Update django.po (#138) 2023-11-04 19:52:43 +01:00
Stan Triepels
b3f494571f Use importerror for b62_decode and avoid deprecation notification (#134) 2023-10-26 14:29:13 +02:00
Patrycja
503127233f Specify build system in pyproject.toml (#131)
Otherwise, trying to build the project with a tool like gpep517 or build
results in using the setuptools legacy backend instead
2023-10-19 22:05:16 +02:00
GDay
e3be22ac63 Release v1.6.1 2023-10-13 14:15:58 +02:00
Stan Triepels
0aaeb8d35d Fix strict versions for python/django - poetry install error (#130) 2023-10-13 14:13:22 +02:00
GDay
750c654940 Release v1.6.0 2023-10-13 00:59:42 +02:00
Stan Triepels
1d2a6059db Add support for Django 5 (#120) 2023-10-12 22:44:19 +02:00
Stan Triepels
54ab399758 Fix for "apps not ready" in Windows and Mac (#116) 2023-10-09 22:04:32 +02:00
Grayknife
bc50219141 Update broken MongoClient link in Docs (#127) 2023-10-07 22:15:49 +02:00
Grayknife
939d0ebef1 Fix German Translation Typo (#124) 2023-09-30 13:28:42 +02:00
Jrog
68af2ae13e Update Add-ons install command in install.rst (#115) 2023-09-14 14:37:12 +02:00
Dustin Blanchard
960f72a1d0 DOCS: Correct health check import in examples.rst (#110)
* Correct health check import in examples.rst

* Update monitor.rst

---------

Co-authored-by: Stan Triepels <1939656+GDay@users.noreply.github.com>
2023-09-05 17:05:47 +02:00
GDay
aa79a6e6b3 Release v1.5.5 2023-09-01 16:36:00 +02:00
Stan Triepels
59d761b574 Add documentation to migrate from django-q to django-q2 (#108) 2023-09-01 14:33:26 +02:00
Stan Triepels
d8cc0cc4f4 Fix not picking up result from falsy result (#107)
* Fix not picking up result from falsy result

* add test and docs

* typo
2023-09-01 02:36:15 +02:00
Rémy Pecqueur
a08ef61ba5 Remove deprecated usage pkg_resources (#103)
List entry points using importlib.metadata (Python >=3.10) or
importlib-metadata>=3.6 (Python < 3.10)
2023-08-01 02:33:31 +02:00
Stan Triepels
7fb7fca176 Move worker, scheduler, pusher and monitor to separate files (#100) 2023-07-02 02:47:05 +02:00
GDay
f8501bfb4c Release v1.5.4 2023-06-29 01:03:57 +02:00
Scott Pashley
23c06a38eb Rerun successful tasks (#99)
* Move logic that resubmits a task and then deletes the original into an action function that can be used for both failure and success resubmission.

* Use the resubmit_task action in the FailAdmin.

* Add the resubmit_task action to the TaskAdmin class.

* Update resubmit failure test to reflect using the new resubmit action.

* Add a test for resubmitting successful tasks.

---------

Co-authored-by: Scott Pashley
2023-06-23 23:05:04 +02:00
GDay
f8321e7f54 Release v1.5.3 2023-05-14 03:22:34 +02:00
Stan Triepels
630b3c62b3 Make processes identifiable with uuid4 (#91)
* make processes identifiable

* update docs
2023-05-14 03:07:38 +02:00
Stan Triepels
227caa7446 Post spawn docs (#95) 2023-05-14 03:04:07 +02:00
David Hoover
4ba9e0338a Add post_spawn signal. (#93)
Co-authored-by: David Hoover <hooverd@arizona.edu>
2023-05-14 02:51:48 +02:00
GDay
fb960104c3 Release v1.5.2 2023-04-13 01:31:59 +02:00
GDay
88bbfd8967 Mention support for 4.2.x in docs 2023-04-13 01:21:46 +02:00
Martijn Jacobs
1f31725f43 Added Django 4.2 to the test matrix, fixed deprecation warning (#89)
* Added Django 4.2 to the CI test matrix

* Positional arguments with TimestampSigner are deprecated
2023-04-12 18:18:03 +02:00
88 changed files with 3338 additions and 2739 deletions

View File

@@ -1,2 +1,4 @@
# flake8, black, isort # flake8, black, isort
b1d000d007f3f77069719523268a0c6256dc0860 b1d000d007f3f77069719523268a0c6256dc0860
# move to ruff formatting/linting
ad4d24e17c9424b17cd8ae65c2def7ecc74e63c1

View File

@@ -11,15 +11,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.8 python-version: 3.11
- name: Install dependencies - name: Install dependencies
run: | run: |
sudo apt-get update sudo apt-get update

View File

@@ -7,12 +7,38 @@ on:
branches: branches:
- master - master
jobs: jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Lint with ruff
run: |
pipx install ruff==0.4.10
ruff format . --check && ruff check .
- name: Check twine
run: |
python -m pip install twine poetry rstcheck
poetry build
rstcheck README.rst
twine check dist/*
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11" ] python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
django: [ "3.2", "4.1" ] django: [ "4.2", "5.0", "5.1"]
exclude:
# django 5.1 does not support 3.8 and 3.9
- python-version: "3.8"
django: "5.1"
- python-version: "3.9"
django: "5.1"
# django 5.0 does not support 3.8 and 3.9
- python-version: "3.8"
django: "5.0"
- python-version: "3.9"
django: "5.0"
services: services:
disque: disque:
@@ -39,16 +65,16 @@ jobs:
- 6379:6379 - 6379:6379
options: --entrypoint redis-server options: --entrypoint redis-server
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies with Django ${{ matrix.django }} - name: Install dependencies with Django ${{ matrix.django }}
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install poetry pip install poetry
poetry add "django==${{ matrix.django }}" poetry add "django==${{ matrix.django }}" --python=${{ matrix.python-version }}
poetry install -E testing poetry install -E testing
- name: Run Tests - name: Run Tests
run: | run: |
@@ -68,16 +94,12 @@ jobs:
finish: finish:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: python:3-slim container: python:3.11-bookworm
steps: steps:
- name: Upload to coveralls - name: Upload to coveralls
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install coveralls flake8 black python -m pip install coveralls
coveralls --service=github --finish coveralls --service=github --finish
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check flake8/black
run: |
flake8 .
black --check .

View File

@@ -1,6 +1,88 @@
# Changelog # Changelog
## [Unreleased](https://github.com/GDay/django-q2/tree/HEAD) ## [v1.7.2](https://github.com/django-q2/django-q2/tree/v1.7.2) (2024-09-09)
- Fix twine check
## [v1.7.1](https://github.com/django-q2/django-q2/tree/v1.7.1) (2024-09-08)
- Fixed date of v1.7.0
- Fixed README.rst formatting which is blocking release of latest version
## [v1.7.0](https://github.com/django-q2/django-q2/tree/v1.7.0) (2024-09-08)
**Merged pull requests:**
- Remove support for Django 3.2 and 4.1 https://github.com/django-q2/django-q2/pull/183
- Fix max attempts for value 1 https://github.com/django-q2/django-q2/pull/185
- Replace black/isort with ruff https://github.com/django-q2/django-q2/pull/188
- Fix repeating task after timeout https://github.com/django-q2/django-q2/pull/184
- fix: Oracle ORM backend compatibility #180 https://github.com/django-q2/django-q2/pull/186
- chore: Update CI for Django 4.2 Python 3.12 support https://github.com/django-q2/django-q2/pull/208
- chore: Add Support Django 5.1 https://github.com/django-q2/django-q2/pull/207
- Call mark_process_dead on worker pid if prometheus_client is installed https://github.com/django-q2/django-q2/pull/212
- Add example project https://github.com/django-q2/django-q2/pull/215
- Add index on succeeded tasks https://github.com/django-q2/django-q2/pull/164
## [v1.6.2](https://github.com/django-q2/django-q2/tree/v1.6.2) (2024-03-05)
**Merged pull requests:**
- Allow different broker on chain https://github.com/django-q2/django-q2/pull/156
- Fix formatting issues in README.rst https://github.com/django-q2/django-q2/pull/159
- Update docs to add cluster option to the async_task https://github.com/django-q2/django-q2/pull/157
- Update release/test dependencies https://github.com/django-q2/django-q2/pull/147
- Fix for Negative Repeat Count in Scheduler https://github.com/django-q2/django-q2/pull/146
- Update django.po https://github.com/django-q2/django-q2/pull/138
- Use importerror for b62_decode and avoid deprecation notification https://github.com/django-q2/django-q2/pull/134
- Specify build system in pyproject.toml https://github.com/django-q2/django-q2/pull/131
## [v1.6.1](https://github.com/django-q2/django-q2/tree/v1.6.1) (2023-10-13)
**Merged pull requests:**
- Fix strict versions for python/django https://github.com/django-q2/django-q2/pull/130
## [v1.6.0](https://github.com/django-q2/django-q2/tree/v1.6.0) (2023-10-12)
**Merged pull requests:**
- Add support for Django 5 and python 12 https://github.com/django-q2/django-q2/pull/120
- Fix for "apps not ready" in Windows and Mac https://github.com/django-q2/django-q2/pull/116
- Update broken MongoClient link in Docs https://github.com/django-q2/django-q2/pull/127
- Fix German Translation Typo https://github.com/django-q2/django-q2/pull/124
- Update Add-ons install command in install.rst https://github.com/django-q2/django-q2/pull/115
- DOCS: Correct health check import in examples.rst https://github.com/django-q2/django-q2/pull/110
## [v1.5.5](https://github.com/django-q2/django-q2/tree/v1.5.5) (2023-09-01)
**Merged pull requests:**
- Add documentation to migrate from django-q to django-q2 https://github.com/django-q2/django-q2/pull/108
- Fix not picking up result from falsy result https://github.com/django-q2/django-q2/pull/107
- Remove deprecated usage pkg_resources https://github.com/django-q2/django-q2/pull/103
- Move worker, scheduler, pusher and monitor to separate files https://github.com/django-q2/django-q2/pull/100
## [v1.5.4](https://github.com/GDay/django-q2/tree/v1.5.4) (2023-06-29)
**Merged pull requests:**
- Rerun successful tasks https://github.com/django-q2/django-q2/pull/99
## [v1.5.3](https://github.com/GDay/django-q2/tree/v1.5.3) (2023-05-14)
**Merged pull requests:**
- Add post_spawn signal. https://github.com/django-q2/django-q2/pull/93
- Post spawn docs https://github.com/django-q2/django-q2/pull/95
- Make processes identifiable with uuid4 https://github.com/django-q2/django-q2/pull/91
## [v1.5.2](https://github.com/GDay/django-q2/tree/v1.5.2) (2023-04-13)
**Merged pull requests:**
- Added Django 4.2 to the test matrix, fixed deprecation warning https://github.com/GDay/django-q2/pull/89
- Updated docs to show support for 4.2
## [v1.5.1](https://github.com/GDay/django-q2/tree/v1.5.1) (2023-04-02) ## [v1.5.1](https://github.com/GDay/django-q2/tree/v1.5.1) (2023-04-02)

View File

@@ -1,4 +1,4 @@
FROM python:3.9 FROM python:3.12
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
RUN mkdir -p /app RUN mkdir -p /app

View File

@@ -8,10 +8,10 @@ ENV PYTHONUNBUFFERED 1
ENV SHELL /bin/bash ENV SHELL /bin/bash
# Upgrades pip # Upgrades pip
RUN pip install --upgrade pip RUN pip install -U pip setuptools
# Install poetry # Install poetry
RUN pip install poetry RUN pip install poetry==1.8.2
WORKDIR /app WORKDIR /app

21
Makefile Normal file
View File

@@ -0,0 +1,21 @@
dev:
docker compose -f web-docker-compose.yaml up
test:
docker-compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run pytest
shell:
docker-compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run python manage.py shell
makemigrations:
docker-compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run python manage.py makemigrations
migrate:
docker-compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run python manage.py migrate
createsuperuser:
docker compose -f web-docker-compose.yaml run --rm web python manage.py createsuperuser
format:
docker compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run ruff format .
docker compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run ruff check . --fix

View File

@@ -1,11 +1,9 @@
A multiprocessing distributed task queue for Django A multiprocessing distributed task queue for Django
--------------------------------------------------- ---------------------------------------------------
|image0| |image1| |docs| |downloads| |image0| |image1| |downloads|
:: Django Q2 is a fork of Django Q. Big thanks to Ilan Steemers for starting this project. Unfortunately, development has stalled since June 2021. Django Q2 is the new updated version of Django Q, with dependencies updates, docs updates and several bug fixes. Original repository: https://github.com/Koed00/django-q
Django Q2 is a fork of Django Q. Big thanks to Ilan Steemers for starting this project. Unfortunately, development has stalled since June 2021. Django Q2 is the new updated version of Django Q, with dependencies updates, docs updates and several bug fixes. Original repository: https://github.com/Koed00/django-q
Features Features
~~~~~~~~ ~~~~~~~~
@@ -27,7 +25,7 @@ Changes compared to the original Django-Q:
- Dropped support for Disque (hasn't been updated in a long time) - Dropped support for Disque (hasn't been updated in a long time)
- Dropped Redis, Arrow and Blessed dependencies - Dropped Redis, Arrow and Blessed dependencies
- Updated all current dependencies - Updated all current dependencies
- Added tests for Django 4.x - Added tests for Django 4.x and 5.x
- Added Turkish language - Added Turkish language
- Improved admin area - Improved admin area
- Fixed a lot of issues - Fixed a lot of issues
@@ -37,10 +35,10 @@ See the `changelog <https://github.com/GDay/django-q2/blob/master/CHANGELOG.md>`
Requirements Requirements
~~~~~~~~~~~~ ~~~~~~~~~~~~
- `Django <https://www.djangoproject.com>`__ > = 3.2 - `Django <https://www.djangoproject.com>`__ > = 4.2
- `Django-picklefield <https://github.com/gintas/django-picklefield>`__ - `Django-picklefield <https://github.com/gintas/django-picklefield>`__
Tested with: Python 3.8, 3.9, 3.10, 3.11 Django 3.2.X and 4.1.X Tested with: Python 3.8, 3.9, 3.10, 3.11 and 3.12. Works with Django 4.2.X and 5.0.X
Brokers Brokers
~~~~~~~ ~~~~~~~
@@ -73,7 +71,6 @@ Installation
Read the full documentation at `https://django-q2.readthedocs.org <https://django-q2.readthedocs.org>`__ Read the full documentation at `https://django-q2.readthedocs.org <https://django-q2.readthedocs.org>`__
Configuration Configuration
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@@ -104,6 +101,8 @@ For full configuration options, see the `configuration documentation <https://dj
Management Commands Management Commands
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
For the management commands to work, you will need to install Blessed: <https://github.com/jquast/blessed>
Start a cluster with:: Start a cluster with::
$ python manage.py qcluster $ python manage.py qcluster
@@ -154,7 +153,6 @@ Use `async_task` from your code to quickly offload tasks:
For more info see `Tasks <https://django-q2.readthedocs.org/en/latest/tasks.html>`__ For more info see `Tasks <https://django-q2.readthedocs.org/en/latest/tasks.html>`__
Schedule Schedule
~~~~~~~~ ~~~~~~~~
@@ -199,6 +197,22 @@ Admin page or directly from your code:
For more info check the `Schedules <https://django-q2.readthedocs.org/en/latest/schedules.html>`__ documentation. For more info check the `Schedules <https://django-q2.readthedocs.org/en/latest/schedules.html>`__ documentation.
Development
~~~~~~~~~~~
There is an example project that you can use to develop with. Docker (compose) is being used to set everything up.
Please note that you will have to restart the django-q container when changes have been made to tasks or django-q.
You can start the example project with:
.. code:: bash
make dev
Create a superuser with:
.. code:: bash
make createsuperuser
Testing Testing
~~~~~~~ ~~~~~~~
@@ -207,7 +221,7 @@ Running tests is easy with docker compose, it will also start the necessary data
.. code:: bash .. code:: bash
docker-compose -f test-services-docker-compose.yaml run --rm django-q2 poetry run pytest make test
Locale Locale
~~~~~~ ~~~~~~
@@ -215,12 +229,6 @@ Locale
Currently available in English, German, Turkish, and French. Currently available in English, German, Turkish, and French.
Translation pull requests are always welcome. Translation pull requests are always welcome.
Todo
~~~~
- Better tests and coverage
- Less dependencies?
Acknowledgements Acknowledgements
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@@ -237,9 +245,5 @@ Acknowledgements
:target: https://github.com/GDay/django-q2/actions?query=workflow%3Atests :target: https://github.com/GDay/django-q2/actions?query=workflow%3Atests
.. |image1| image:: https://coveralls.io/repos/github/GDay/django-q2/badge.svg?branch=master .. |image1| image:: https://coveralls.io/repos/github/GDay/django-q2/badge.svg?branch=master
:target: https://coveralls.io/github/GDay/django-q2?branch=master :target: https://coveralls.io/github/GDay/django-q2?branch=master
.. |docs| image:: https://readthedocs.org/projects/docs/badge/?version=latest
:alt: Documentation Status
:scale: 100
:target: https://django-q2.readthedocs.org/
.. |downloads| image:: https://img.shields.io/pypi/dm/django-q2 .. |downloads| image:: https://img.shields.io/pypi/dm/django-q2
:target: https://img.shields.io/pypi/dm/django-q2 :target: https://img.shields.io/pypi/dm/django-q2

View File

@@ -1,6 +1,6 @@
import django import django
VERSION = (1, 5, 1) VERSION = (1, 7, 2)
if django.VERSION < (3, 2): if django.VERSION < (3, 2):
default_app_config = "django_q.apps.DjangoQConfig" default_app_config = "django_q.apps.DjangoQConfig"

View File

@@ -1,4 +1,5 @@
"""Admin module for Django.""" """Admin module for Django."""
from django.contrib import admin from django.contrib import admin
from django.db.models.expressions import OuterRef, Subquery from django.db.models.expressions import OuterRef, Subquery
from django.urls import reverse from django.urls import reverse
@@ -10,10 +11,37 @@ from django_q.models import Failure, OrmQ, Schedule, Success, Task
from django_q.tasks import async_task from django_q.tasks import async_task
def resubmit_task(model_admin, request, queryset):
"""Submit selected tasks back to the queue."""
for task in queryset:
async_task(
task.func,
*task.args or (),
hook=task.hook,
group=task.group,
cluster=task.cluster,
**task.kwargs or {},
)
if isinstance(model_admin, FailAdmin):
task.delete()
resubmit_task.short_description = _("Resubmit selected tasks to queue")
class TaskAdmin(admin.ModelAdmin): class TaskAdmin(admin.ModelAdmin):
"""model admin for success tasks.""" """model admin for success tasks."""
list_display = ("name", "group", "func", "cluster", "started", "stopped", "time_taken") list_display = (
"name",
"group",
"func",
"cluster",
"started",
"stopped",
"time_taken",
)
actions = [resubmit_task]
def has_add_permission(self, request): def has_add_permission(self, request):
"""Don't allow adds.""" """Don't allow adds."""
@@ -33,27 +61,24 @@ class TaskAdmin(admin.ModelAdmin):
return list(self.readonly_fields) + [field.name for field in obj._meta.fields] return list(self.readonly_fields) + [field.name for field in obj._meta.fields]
def retry_failed(FailAdmin, request, queryset):
"""Submit selected tasks back to the queue."""
for task in queryset:
async_task(task.func, *task.args or (), hook=task.hook,
group=task.group, cluster=task.cluster, **task.kwargs or {})
task.delete()
retry_failed.short_description = _("Resubmit selected tasks to queue")
class FailAdmin(admin.ModelAdmin): class FailAdmin(admin.ModelAdmin):
"""model admin for failed tasks.""" """model admin for failed tasks."""
list_display = ("name", "group", "func", "cluster", "started", "stopped", "short_result") list_display = (
"name",
"group",
"func",
"cluster",
"started",
"stopped",
"short_result",
)
def has_add_permission(self, request): def has_add_permission(self, request):
"""Don't allow adds.""" """Don't allow adds."""
return False return False
actions = [retry_failed] actions = [resubmit_task]
search_fields = ("name", "func", "group") search_fields = ("name", "func", "group")
list_filter = ("group", "cluster") list_filter = ("group", "cluster")
readonly_fields = [] readonly_fields = []
@@ -124,7 +149,17 @@ class QueueAdmin(admin.ModelAdmin):
"""queue admin for ORM broker""" """queue admin for ORM broker"""
list_display = ("id", "key", "name", "group", "func", "lock", "task_id") list_display = ("id", "key", "name", "group", "func", "lock", "task_id")
fields = ("key", "lock", "task_id", "name", "group", "func", "args", "kwargs", "q_options") fields = (
"key",
"lock",
"task_id",
"name",
"group",
"func",
"args",
"kwargs",
"q_options",
)
readonly_fields = fields[2:] readonly_fields = fields[2:]
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):

View File

@@ -182,7 +182,7 @@ def get_broker(list_key: str = None) -> Broker:
return ironmq.IronMQBroker(list_key=list_key) return ironmq.IronMQBroker(list_key=list_key)
# SQS # SQS
elif type(Conf.SQS) == dict: elif isinstance(Conf.SQS, dict):
from django_q.brokers import aws_sqs from django_q.brokers import aws_sqs
return aws_sqs.Sqs(list_key=list_key) return aws_sqs.Sqs(list_key=list_key)

View File

@@ -37,7 +37,9 @@ class ORM(Broker):
def lock_size(self) -> int: def lock_size(self) -> int:
return ( return (
self.get_connection().filter(key=self.list_key, lock__gt=timezone.now()).count() self.get_connection()
.filter(key=self.list_key, lock__gt=timezone.now())
.count()
) )
def purge_queue(self): def purge_queue(self):
@@ -62,7 +64,9 @@ class ORM(Broker):
return package.pk return package.pk
def dequeue(self): def dequeue(self):
tasks = self.get_connection().filter(key=self.list_key, lock__lt=timezone.now())[ tasks = self.get_connection().filter(
key=self.list_key, lock__lt=timezone.now()
)[
0 : Conf.BULK # noqa: E203 0 : Conf.BULK # noqa: E203
] ]
if tasks: if tasks:

View File

@@ -1,12 +1,8 @@
# Standard # Standard
from django_q.scheduler import Scheduler
from django_q.puller import Puller
from django_q.worker import Pool
import signal import signal
from django_q.monitor import Monitor
import socket import socket
import uuid import uuid
from multiprocessing import Event, Process, current_process from multiprocessing import Event, Process, Value, current_process
from time import sleep from time import sleep
# Django # Django
@@ -24,17 +20,23 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Local # Local
import django_q.tasks
from django_q.brokers import Broker, get_broker from django_q.brokers import Broker, get_broker
from django_q.conf import ( from django_q.conf import (
Conf, Conf,
get_ppid, get_ppid,
logger, logger,
prometheus_multiprocess,
psutil, psutil,
setproctitle, setproctitle,
) )
from django_q.humanhash import humanize from django_q.humanhash import humanize
from django_q.monitor import monitor
from django_q.pusher import pusher
from django_q.queues import Queue
from django_q.scheduler import scheduler
from django_q.status import Stat, Status from django_q.status import Stat, Status
from django_q.worker import worker
class Cluster: class Cluster:
def __init__(self, broker: Broker = None): def __init__(self, broker: Broker = None):
@@ -59,6 +61,7 @@ class Cluster:
self.start_event = Event() self.start_event = Event()
self.sentinel = Process( self.sentinel = Process(
target=Sentinel, target=Sentinel,
name=f"Process-{uuid.uuid4().hex}",
args=( args=(
self.stop_event, self.stop_event,
self.start_event, self.start_event,
@@ -148,17 +151,22 @@ class Sentinel:
self.tob = timezone.now() self.tob = timezone.now()
self.stop_event = stop_event self.stop_event = stop_event
self.start_event = start_event self.start_event = start_event
self.pool_size = Conf.WORKERS
self.pool = []
self.timeout = timeout or Conf.TIMEOUT self.timeout = timeout or Conf.TIMEOUT
self.event_out = Event() self.task_queue = (
logger.info( Queue(maxsize=Conf.QUEUE_LIMIT) if Conf.QUEUE_LIMIT else Queue()
_("%(name)s main at %(id)s") % {"name": self.name, "id": current_process().pid}
) )
self.result_queue = Queue()
self.event_out = Event()
self.monitor = None
self.pusher = None
if start: if start:
self.start() self.start()
def queue_name(self): def queue_name(self):
# multi-queue: cluster name is (broker's) queue_name # multi-queue: cluster name is (broker's) queue_name
return self.broker.list_key if self.broker else '--' return self.broker.list_key if self.broker else "--"
def start(self): def start(self):
self.broker.ping() self.broker.ping()
@@ -169,140 +177,201 @@ class Sentinel:
if not self.start_event.is_set() and not self.stop_event.is_set(): if not self.start_event.is_set() and not self.stop_event.is_set():
return Conf.STARTING return Conf.STARTING
elif self.start_event.is_set() and not self.stop_event.is_set(): elif self.start_event.is_set() and not self.stop_event.is_set():
if self.monitor.is_idle and self.pool.is_done: if self.result_queue.empty() and self.task_queue.empty():
return Conf.IDLE return Conf.IDLE
return Conf.WORKING return Conf.WORKING
elif self.stop_event.is_set() and self.start_event.is_set(): elif self.stop_event.is_set() and self.start_event.is_set():
if self.monitor.is_alive or self.puller.is_alive or len(self.pool.workers) > 0: if self.monitor.is_alive() or self.pusher.is_alive() or len(self.pool) > 0:
return Conf.STOPPING return Conf.STOPPING
return Conf.STOPPED return Conf.STOPPED
def spawn_process(self, target, *args) -> Process:
"""
:type target: function or class
"""
p = Process(target=target, args=args, name=f"Process-{uuid.uuid4().hex}")
p.daemon = True
if target == worker:
p.daemon = Conf.DAEMONIZE_WORKERS
p.timer = args[2]
self.pool.append(p)
p.start()
return p
def spawn_pusher(self) -> Process:
return self.spawn_process(pusher, self.task_queue, self.event_out, self.broker)
def spawn_worker(self):
self.spawn_process(
worker, self.task_queue, self.result_queue, Value("f", -1), self.timeout
)
def spawn_monitor(self) -> Process:
return self.spawn_process(monitor, self.result_queue, self.broker)
def reincarnate(self, process):
"""
:param process: the process to reincarnate
:type process: Process or None
"""
# close connections before spawning new process
if not Conf.SYNC:
db.connections.close_all()
if process == self.monitor:
self.monitor = self.spawn_monitor()
logger.critical(
_("reincarnated monitor %(name)s after sudden death")
% {"name": process.name}
)
elif process == self.pusher:
self.pusher = self.spawn_pusher()
logger.critical(
_("reincarnated pusher %(name)s after sudden death")
% {"name": process.name}
)
else:
if prometheus_multiprocess:
prometheus_multiprocess.mark_process_dead(process.pid)
self.pool.remove(process)
self.spawn_worker()
if process.timer.value == 0:
# only need to terminate on timeout, otherwise we risk destabilizing
# the queues
task_name = ""
if psutil:
try:
process_name = psutil.Process(process.pid).name()
name_splits = process_name.split(" ")
task_name = (
name_splits[3]
if len(name_splits) >= 4 and name_splits[2] == "processing"
else ""
)
except psutil.NoSuchProcess:
pass
process.terminate()
if task_name:
msg = _(
"reincarnated worker %(name)s after timeout while processing task %(task_name)s"
) % {"name": process.name, "task_name": task_name}
else:
msg = _("reincarnated worker %(name)s after timeout") % {
"name": process.name
}
logger.critical(msg)
elif int(process.timer.value) == -2:
logger.info(_("recycled worker %(name)s") % {"name": process.name})
else:
logger.critical(
_("reincarnated worker %(name)s after death")
% {"name": process.name}
)
self.reincarnations += 1
def spawn_cluster(self): def spawn_cluster(self):
self.pool = []
Stat(self).save()
# close connections before spawning new process # close connections before spawning new process
if not Conf.SYNC: if not Conf.SYNC:
db.connections.close_all() db.connections.close_all()
# spawn worker pool # spawn worker pool
self.pool = Pool() for __ in range(self.pool_size):
self.puller = Puller() self.spawn_worker()
self.monitor = Monitor() # spawn auxiliary
self.scheduler = Scheduler() self.monitor = self.spawn_monitor()
self.pusher = self.spawn_pusher()
# set worker cpu affinity if needed # set worker cpu affinity if needed
if psutil and Conf.CPU_AFFINITY: if psutil and Conf.CPU_AFFINITY:
set_cpu_affinity(Conf.CPU_AFFINITY, [w.process.pid for w in self.pool.workers]) set_cpu_affinity(Conf.CPU_AFFINITY, [w.pid for w in self.pool])
Stat(self).save()
def guard(self): def guard(self):
logger.info( logger.info(
_("%(name)s guarding cluster %(cluster_name)s") _("%(name)s guarding cluster %(cluster_name)s")
% { % {
"name": current_process().name, "name": current_process().name,
"cluster_name": humanize(self.cluster_id.hex) + f" [{self.queue_name()}]", "cluster_name": humanize(self.cluster_id.hex)
+ f" [{self.queue_name()}]",
} }
) )
self.start_event.set() self.start_event.set()
Stat(self).save() Stat(self).save()
logger.info( logger.info(
_("Q Cluster %(cluster_name)s running.") _("Q Cluster %(cluster_name)s running.")
% {"cluster_name": humanize(self.cluster_id.hex) + f" [{self.queue_name()}]"} % {
"cluster_name": humanize(self.cluster_id.hex)
+ f" [{self.queue_name()}]"
}
) )
counter = 0 counter = 0
cycle = Conf.GUARD_CYCLE # guard loop sleep in seconds
# Guard loop. Runs at least once # Guard loop. Runs at least once
while not self.stop_event.is_set() or not counter: while not self.stop_event.is_set() or not counter:
# Check if the pool of workers is healthy # Check Workers
logger.info("Check if pool is healthy") for p in self.pool:
if not self.pool.is_healthy: with p.timer.get_lock():
# reincarnate workers that died # Are you alive?
print("reincarnate workers") if not p.is_alive() or p.timer.value == 0:
self.pool.reincarnate_stopped_workers() self.reincarnate(p)
continue
print("Check if puller is healthy") # Decrement timer if work is being done
if not self.puller.is_alive: if p.timer.value > 0:
self.puller.reincarnate_process() p.timer.value -= cycle
# Check Monitor
print("Check if monitor is healthy") if not self.monitor.is_alive():
if not self.monitor.is_alive: self.reincarnate(self.monitor)
self.monitor.reincarnate_process() # Check Pusher
if not self.pusher.is_alive():
print("Check if scheduler is healthy") self.reincarnate(self.pusher)
if not self.scheduler.is_alive: # Call scheduler once a minute (or so)
self.scheduler.reincarnate_process() counter += cycle
if counter >= 30 and Conf.SCHEDULER:
counter = 0
print("add tasks and mark workers idle") scheduler(broker=self.broker)
for worker in self.pool.get_done_workers(): # Save current status
# put result in task_queue to be picked up by monitor for processing
self.monitor.add_task(worker.get_result())
# mark task back to idle or reincarnate to be picked up for a new task
if worker.is_recycle:
worker.reincarnate_process()
else:
worker.mark_idle()
# check if monitor has items to process
print("run monitor item")
self.monitor.run_item()
print("Add task to worker pool")
if self.puller.has_results:
self.pool.add_task(self.puller.get_result())
# delegate tasks to workers that are now available
print("delegate tasks")
self.pool.delegate_tasks()
logger.info("sleep")
counter += 1
sleep(Conf.GUARD_CYCLE)
Stat(self).save() Stat(self).save()
sleep(cycle)
self.stop() self.stop()
def stop(self): def stop(self):
Stat(self).save()
name = current_process().name name = current_process().name
logger.info(_("%(name)s stopping cluster processes") % {"name": name}) logger.info(_("%(name)s stopping cluster processes") % {"name": name})
# Stopping guard # Stopping pusher
self.stop_event.set() self.event_out.set()
logger.debug(_("Guard has stopped")) # Wait for it to stop
# Stop scheduler while self.pusher.is_alive():
self.scheduler.stop_scheduler() sleep(0.1)
Stat(self).save()
# End all workers gracefully # Put poison pills in the queue
for __ in range(Conf.WORKERS): for __ in range(len(self.pool)):
self.pool.add_task("STOP") self.task_queue.put("STOP")
self.task_queue.close()
# make sure the tasks queue in the pool is empty and workers are idle max timeout 20 sec # wait for the task queue to empty
time_passed = 0 self.task_queue.join_thread()
while not self.pool.is_done and time_passed <= 20: # Wait for all the workers to exit
self.monitor.run_item() while len(self.pool):
self.pool.delegate_tasks() for p in self.pool:
time_passed += 0.5 if not p.is_alive():
sleep(0.5) self.pool.remove(p)
if time_passed >= 20: sleep(0.1)
logger.error(_("Couldn't terminate tasks within 20 seconds, killing processes now")) Stat(self).save()
for worker in self.pool.workers: # Finally stop the monitor
worker.process.kill() self.result_queue.put("STOP")
self.result_queue.close()
logger.debug(_("All tasks were processed and workers where stopped")) # Wait for the result queue to empty
self.result_queue.join_thread()
self.monitor.add_task("STOP") logger.info(_("%(name)s waiting for the monitor.") % {"name": name})
while not self.monitor.is_done: # Wait for everything to close or time out
# in the case the monitor was behind, let's run through all count = 0
self.monitor.run_item() if not self.timeout:
sleep(0.5) self.timeout = 30
while self.status() == Conf.STOPPING and count < self.timeout * 10:
logger.debug(_("All tasks were saved")) sleep(0.1)
Stat(self).save()
self.puller.stop_puller() count += 1
# Final status
# make sure all processes are terminated Stat(self).save()
for worker in self.pool.workers:
worker.process.terminate()
self.monitor.process.terminate()
self.puller.process.terminate()
self.scheduler.process.terminate()
logger.debug(_("All processes were terminated"))
def set_cpu_affinity(n: int, process_ids: list, actual: bool = not Conf.TESTING): def set_cpu_affinity(n: int, process_ids: list, actual: bool = not Conf.TESTING):

View File

@@ -1,15 +1,21 @@
import logging import logging
import os import os
import sys
from copy import deepcopy from copy import deepcopy
from multiprocessing import cpu_count from multiprocessing import cpu_count
from signal import signal from signal import signal
from warnings import warn from warnings import warn
import pkg_resources
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from queue import Queue from django_q.queues import Queue
# The "selectable" entry points were introduced in importlib_metadata 3.6 and Python 3.10.
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
# optional # optional
try: try:
@@ -32,6 +38,11 @@ try:
except ModuleNotFoundError: except ModuleNotFoundError:
setproctitle = None setproctitle = None
try:
from prometheus_client import multiprocess as prometheus_multiprocess
except ModuleNotFoundError:
prometheus_multiprocess = None
class Conf: class Conf:
""" """
@@ -44,15 +55,18 @@ class Conf:
conf = {} conf = {}
_Q_CLUSTER_NAME = os.getenv("Q_CLUSTER_NAME") _Q_CLUSTER_NAME = os.getenv("Q_CLUSTER_NAME")
if _Q_CLUSTER_NAME and _Q_CLUSTER_NAME != conf.get("name") and \ if (
_Q_CLUSTER_NAME != conf.get("cluster_name"): _Q_CLUSTER_NAME
and _Q_CLUSTER_NAME != conf.get("name")
and _Q_CLUSTER_NAME != conf.get("cluster_name")
):
conf["cluster_name"] = _Q_CLUSTER_NAME conf["cluster_name"] = _Q_CLUSTER_NAME
alt_conf = conf.pop("ALT_CLUSTERS") alt_conf = conf.pop("ALT_CLUSTERS")
if isinstance(alt_conf, dict): if isinstance(alt_conf, dict):
alt_conf = alt_conf.get(_Q_CLUSTER_NAME) alt_conf = alt_conf.get(_Q_CLUSTER_NAME)
if isinstance(alt_conf, dict): if isinstance(alt_conf, dict):
alt_conf.pop('name', None) alt_conf.pop("name", None)
alt_conf.pop('cluster_name', None) alt_conf.pop("cluster_name", None)
conf.update(alt_conf) conf.update(alt_conf)
# Redis server configuration . Follows standard redis keywords # Redis server configuration . Follows standard redis keywords
@@ -91,7 +105,7 @@ class Conf:
CLUSTER_NAME = conf.get("cluster_name", PREFIX) CLUSTER_NAME = conf.get("cluster_name", PREFIX)
# Log output level # Log output level
LOG_LEVEL = conf.get("log_level", "DEBUG") LOG_LEVEL = conf.get("log_level", "INFO")
# Maximum number of successful tasks kept in the database. 0 saves everything. # Maximum number of successful tasks kept in the database. 0 saves everything.
# -1 saves none # -1 saves none
@@ -112,7 +126,7 @@ class Conf:
) )
# Guard loop sleep in seconds. Should be between 0 and 60 seconds. # Guard loop sleep in seconds. Should be between 0 and 60 seconds.
GUARD_CYCLE = conf.get("guard_cycle", 1) GUARD_CYCLE = conf.get("guard_cycle", 0.5)
# Disable the scheduler # Disable the scheduler
SCHEDULER = conf.get("scheduler", True) SCHEDULER = conf.get("scheduler", True)
@@ -240,7 +254,6 @@ class Conf:
# logger # logger
logger = logging.getLogger("django-q") logger = logging.getLogger("django-q")
# Set up standard logging handler in case there is none # Set up standard logging handler in case there is none
if not logger.hasHandlers(): if not logger.hasHandlers():
logger.setLevel(level=getattr(logging, Conf.LOG_LEVEL)) logger.setLevel(level=getattr(logging, Conf.LOG_LEVEL))
@@ -255,7 +268,6 @@ if not logger.hasHandlers():
# Error Reporting Interface # Error Reporting Interface
class ErrorReporter: class ErrorReporter:
# initialize with iterator of reporters (better name, targets?) # initialize with iterator of reporters (better name, targets?)
def __init__(self, reporters): def __init__(self, reporters):
self.targets = [target for target in reporters] self.targets = [target for target in reporters]
@@ -274,9 +286,7 @@ if Conf.ERROR_REPORTER:
# iterate through the configured error reporters, # iterate through the configured error reporters,
# and instantiate an ErrorReporter using the provided config # and instantiate an ErrorReporter using the provided config
for name, conf in error_conf.items(): for name, conf in error_conf.items():
for entry in pkg_resources.iter_entry_points( for entry in entry_points(group="djangoq.errorreporters", name=name):
"djangoq.errorreporters", name
):
Reporter = entry.load() Reporter = entry.load()
reporters.append(Reporter(**conf)) reporters.append(Reporter(**conf))
error_reporter = ErrorReporter(reporters) error_reporter = ErrorReporter(reporters)

View File

@@ -2,16 +2,24 @@ import datetime
import time import time
import zlib import zlib
from django.core.signing import BadSignature, JSONSerializer, SignatureExpired from django.core.signing import (
BadSignature,
JSONSerializer,
SignatureExpired,
b64_decode,
dumps,
)
from django.core.signing import Signer as Sgnr from django.core.signing import Signer as Sgnr
from django.core.signing import TimestampSigner as TsS from django.core.signing import TimestampSigner as TsS
from django.core.signing import b64_decode, dumps
try: try:
from django.core.signing import base62 from django.core.signing import b62_decode
except ImportError: except ImportError:
# For django < 4.0 # fallback for django 3.x
from django.utils.baseconv import base62 from django.utils.baseconv import base62
b62_decode = base62.decode
from django.utils.crypto import constant_time_compare from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes, force_str from django.utils.encoding import force_bytes, force_str
@@ -37,7 +45,9 @@ def loads(
""" """
# TimestampSigner.unsign() returns str but base64 and zlib compression # TimestampSigner.unsign() returns str but base64 and zlib compression
# operate on bytes. # operate on bytes.
base64d = force_bytes(TimestampSigner(key, salt=salt).unsign(s, max_age=max_age)) base64d = force_bytes(
TimestampSigner(key=key, salt=salt).unsign(s, max_age=max_age)
)
decompress = False decompress = False
if base64d[:1] == b".": if base64d[:1] == b".":
# It's compressed; uncompress it first # It's compressed; uncompress it first
@@ -74,7 +84,7 @@ class TimestampSigner(Signer, TsS):
""" """
result = super(TimestampSigner, self).unsign(value) result = super(TimestampSigner, self).unsign(value)
value, timestamp = result.rsplit(self.sep, 1) value, timestamp = result.rsplit(self.sep, 1)
timestamp = base62.decode(timestamp) timestamp = b62_decode(timestamp)
if max_age is not None: if max_age is not None:
if isinstance(max_age, datetime.timedelta): if isinstance(max_age, datetime.timedelta):
max_age = max_age.total_seconds() max_age = max_age.total_seconds()

View File

@@ -1,26 +1,7 @@
import signal
from typing import Optional
class TimeoutException(SystemExit): class TimeoutException(SystemExit):
"""Exception for when a worker takes too long to complete a task""" """
Exception for when a worker takes too long to complete a task
Raising SystemExit will make sure the function terminates gracefully.
"""
pass pass
class TimeoutHandler:
def __init__(self, timeout: Optional[int] = None):
self._timeout = timeout
def raise_timeout_exception(self, signum, frame):
raise TimeoutException('Task exceeded maximum timeout value '
'({0} seconds)'.format(self._timeout))
def __enter__(self):
if self._timeout is None:
return
signal.signal(signal.SIGALRM, self.raise_timeout_exception)
signal.alarm(self._timeout)
def __exit__(self, exc_type, exc_value, traceback):
"""When getting out of the timeout, reset the alarm, so it won't trigger"""
signal.alarm(0)
signal.signal(signal.SIGALRM, signal.SIG_DFL)

View File

@@ -1,43 +0,0 @@
from copy import Error
from django_q.worker import WorkerProcess
from django_q.monitor import Monitor
from django_q.models import Task
from typing import Optional, Sequence, Tuple
from django_q.conf import logger
from django_q.queue_task import QueueTask
from django_q.puller import Puller
from django_q.scheduler import Scheduler
def run_scheduler_once(broker=None) -> None:
Scheduler.schedule_tasks(broker)
def get_scheduled_tasks(broker=None) -> Sequence[QueueTask]:
try:
return Puller.get_tasks_from_broker(broker=broker)
except ValueError:
logger.exception("Couldn't get items from broker")
return []
def run_task(task=None) -> QueueTask:
if task is None:
scheduled_tasks = get_scheduled_tasks()
if not len(scheduled_tasks):
raise ValueError("No tasks scheduled and no task given to run for worker")
task = scheduled_tasks[0]
return WorkerProcess.run_task(task)
def save_task(task, broker=None) -> Tuple[QueueTask, Optional[Task]]:
return Monitor.save_task(task, broker)
def run_cluster_once(workers, tasks=[], broker=None) -> None:
if not len(tasks):
run_scheduler_once(broker=broker)
tasks = get_scheduled_tasks(broker=broker)
for idx, worker in enumerate(range(workers)):
if len(tasks) >= idx + 1:
task = run_task(tasks[idx])
save_task(task, broker=broker)

View File

@@ -4,6 +4,7 @@ humanhash: Human-readable representations of digests.
The simplest ways to use this module are the :func:`humanize` and :func:`uuid` The simplest ways to use this module are the :func:`humanize` and :func:`uuid`
functions. For tighter control over the output, see :class:`HumanHasher`. functions. For tighter control over the output, see :class:`HumanHasher`.
""" """
import operator import operator
import uuid as uuidlib import uuid as uuidlib
from argparse import ArgumentError from argparse import ArgumentError
@@ -270,7 +271,6 @@ DEFAULT_WORDLIST = (
class HumanHasher: class HumanHasher:
""" """
Transforms hex digests to human-readable strings. Transforms hex digests to human-readable strings.
@@ -290,7 +290,6 @@ class HumanHasher:
self.wordlist = wordlist self.wordlist = wordlist
def humanize(self, hexdigest, words=4, separator="-"): def humanize(self, hexdigest, words=4, separator="-"):
""" """
Humanize a given hexadecimal digest. Humanize a given hexadecimal digest.
@@ -314,7 +313,6 @@ class HumanHasher:
@staticmethod @staticmethod
def compress(bytes, target): def compress(bytes, target):
""" """
Compress a list of byte values to a fixed target length. Compress a list of byte values to a fixed target length.
@@ -353,7 +351,6 @@ class HumanHasher:
return checksums return checksums
def uuid(self, **params): def uuid(self, **params):
""" """
Generate a UUID with a human-readable representation. Generate a UUID with a human-readable representation.

View File

@@ -318,7 +318,7 @@ msgstr "Anzahl Minuten für den Typ 'Minuten'"
#: models.py:205 #: models.py:205
msgid "Repeats" msgid "Repeats"
msgstr "Wiederhohlungen" msgstr "Wiederholungen"
#: models.py:205 #: models.py:205
msgid "n = n times, -1 = forever" msgid "n = n times, -1 = forever"

View File

@@ -303,7 +303,7 @@ msgstr "Bimestriel"
#: models.py:194 #: models.py:194
msgid "Quarterly" msgid "Quarterly"
msgstr "Tous les quart-dheure" msgstr "Trimestriel"
#: models.py:195 #: models.py:195
msgid "Yearly" msgid "Yearly"

View File

@@ -1,8 +1,9 @@
import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_q.cluster import Cluster from django_q.cluster import Cluster
import os
class Command(BaseCommand): class Command(BaseCommand):
@@ -23,7 +24,7 @@ class Command(BaseCommand):
dest="cluster_name", dest="cluster_name",
default=None, default=None,
help="Set alternative cluster name instead of the name in Q_CLUSTER settings (for multi-queue setup). " help="Set alternative cluster name instead of the name in Q_CLUSTER settings (for multi-queue setup). "
"On Linux you should set name through `Q_CLUSTER_NAME=cluster_name python manage.py qcluster` instead." "On Linux you should set name through `Q_CLUSTER_NAME=cluster_name python manage.py qcluster` instead.",
) )
def handle(self, *args, **options): def handle(self, *args, **options):

View File

@@ -1,15 +1,9 @@
from datetime import timedelta
from django_q.brokers import get_broker
from django_q.models import Failure, Schedule, Success
from django_q.status import Stat
from django.db.models import F, Sum
from django.db import connection
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils import timezone
from django_q import VERSION from django_q import VERSION
from django_q.conf import Conf from django_q.conf import Conf
from django_q.monitor_terminal import get_ids, info
class Command(BaseCommand): class Command(BaseCommand):
@@ -34,13 +28,7 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
if options.get("ids", True): if options.get("ids", True):
stat = Stat.get_all() get_ids()
if not stat:
print(_("No clusters appear to be running."))
for s in stat:
print(s.cluster_id)
elif options.get("config", False): elif options.get("config", False):
hide = [ hide = [
"conf", "conf",
@@ -50,7 +38,6 @@ class Command(BaseCommand):
"WORKING", "WORKING",
"SIGNAL_NAMES", "SIGNAL_NAMES",
"STOPPED", "STOPPED",
"SECRET_KEY",
] ]
settings = [ settings = [
a for a in dir(Conf) if not a.startswith("__") and a not in hide a for a in dir(Conf) if not a.startswith("__") and a not in hide
@@ -61,69 +48,4 @@ class Command(BaseCommand):
if value is not None: if value is not None:
self.stdout.write(f"{setting}: {value}") self.stdout.write(f"{setting}: {value}")
else: else:
broker = get_broker() info()
broker.ping()
stats = Stat.get_all(broker=broker)
clusters = len(stats)
workers = 0
reincarnations = 0
for cluster in stats:
workers += len(cluster.workers)
reincarnations += cluster.reincarnations
# calculate tasks pm and avg exec time
tasks_per = 0
per = _("day")
exec_time = 0
last_tasks = Success.objects.filter(
stopped__gte=timezone.now() - timedelta(hours=24)
)
tasks_per_day = last_tasks.count()
if tasks_per_day > 0:
# average execution time over the last 24 hours
if connection.vendor != "sqlite":
exec_time = last_tasks.aggregate(
time_taken=Sum(F("stopped") - F("started"))
)
exec_time = exec_time["time_taken"].total_seconds() / tasks_per_day
else:
# can't sum timedeltas on sqlite
for t in last_tasks:
exec_time += t.time_taken()
exec_time = exec_time / tasks_per_day
# tasks per second/minute/hour/day in the last 24 hours
if tasks_per_day > 24 * 60 * 60:
tasks_per = tasks_per_day / (24 * 60 * 60)
per = _("second")
elif tasks_per_day > 24 * 60:
tasks_per = tasks_per_day / (24 * 60)
per = _("minute")
elif tasks_per_day > 24:
tasks_per = tasks_per_day / 24
per = _("hour")
else:
tasks_per = tasks_per_day
print(
_("-- %(prefix)s %(version)s on %(info)s --")
% {
"prefix": Conf.PREFIX.capitalize(),
"version": ".".join(str(v) for v in VERSION),
"info": broker.info(),
}
)
print(_("Clusters: %(clusters)s") % {"clusters": clusters})
print(_("Workers: %(workers)s") % {"workers": workers})
print(_("Restarts: %(restarts)s") % {"restarts": reincarnations})
print("")
print(_("Queued: %(queue_size)s") % {"queue_size": str(broker.queue_size())})
print(_("Successes: %(success_count)s") % {"success_count": str(Success.objects.count())})
print(_("Failures: %(failure_count)s") % {"failure_count": str(Failure.objects.count())})
print("")
print(_("Schedules: %(schedules_count)s") % {"schedules_count": str(Schedule.objects.count())})
print(_("Tasks/%(per)s: %(amount)s") % {"per": per, "amount": f"{tasks_per:.2f}"})
print(_("Avg time: %(time)s") % {"time": f"{exec_time:.4f}"})

View File

@@ -1,19 +1,7 @@
import curses
from django_q.conf import Conf
import signal
import time
from django_q.status import Stat
from django_q.brokers import get_broker
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils import timezone
import curses
try: from django_q.monitor_terminal import memory
import psutil
except ImportError:
psutil = None
class Command(BaseCommand): class Command(BaseCommand):
@@ -37,103 +25,7 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
memory_stats = MemoryTerminalStats( memory(
run_once=options.get("run_once", False), run_once=options.get("run_once", False),
workers=options.get("workers", False), workers=options.get("workers", False),
) )
curses.wrapper(memory_stats.start)
def get_process_mb(pid):
try:
process = psutil.Process(pid)
mb_used = round(process.memory_info().rss / 1024**2, 2)
except psutil.NoSuchProcess:
mb_used = "NO_PROCESS_FOUND"
return mb_used
class MemoryTerminalStats:
stop_writing = False
def __init__(self, run_once=False, workers=False):
self.run_once = run_once
self.workers = workers
def start(self, stdscr):
self.show_stats()
def on_exit(self, signum, frame):
# exit clean
self.stop_writing = True
def show_stats(self):
signal.signal(signal.SIGTERM, self.on_exit)
signal.signal(signal.SIGINT, self.on_exit)
scr = curses.initscr()
if not broker:
broker = get_broker()
broker.ping()
if not psutil:
scr.addstr(0, 0, 'Cannot start "qmemory" command. Missing "psutil" library.')
scr.refresh()
return
MEMORY_AVAILABLE_LOWEST_PERCENTAGE = 100.0
MEMORY_AVAILABLE_LOWEST_PERCENTAGE_AT = timezone.now()
stats = Stat.get_all(broker=broker)
if not stats:
scr.addstr(1, 0, "Cluster is not running")
scr.refresh()
while not self.stop_writing:
data = []
for stat in stats:
# memory available (%)
memory_available_percentage = round(
psutil.virtual_memory().available
* 100
/ psutil.virtual_memory().total,
2,
)
# memory available (MB)
memory_available = round(
psutil.virtual_memory().available / 1024**2, 2
)
if memory_available_percentage < MEMORY_AVAILABLE_LOWEST_PERCENTAGE:
MEMORY_AVAILABLE_LOWEST_PERCENTAGE = memory_available_percentage
MEMORY_AVAILABLE_LOWEST_PERCENTAGE_AT = timezone.now()
data.append(f"Host: {str(stat.host)}")
data.append(f"ID: {str(stat.cluster_id)[-8:]}")
data.append(f"Available (%): {memory_available_percentage}")
data.append(f"Available (MB): {memory_available}")
data.append(f"Total (MB): {round(psutil.virtual_memory().total / 1024**2, 2)}")
data.append(f"Sentinel (MB): {get_process_mb(stat.sentinel)}")
data.append(f"Monitor (MB): {get_process_mb(getattr(stat, 'monitor', None))}")
if self.workers:
data.append("")
for worker_num in range(Conf.WORKERS):
data.append(f"Worker #{worker_num+1} (MB): {get_process_mb(stat.workers[worker_num])}")
data.append("")
data.append(_("Available lowest: %(memory_percent)s (%(at)s)")
% {
"memory_percent": str(MEMORY_AVAILABLE_LOWEST_PERCENTAGE),
"at": MEMORY_AVAILABLE_LOWEST_PERCENTAGE_AT.strftime(
"%Y-%m-%d %H:%M:%S+00:00"
),
})
for idx, item in enumerate(data):
scr.addstr(idx, 0, item)
scr.refresh()
time.sleep(0.5)
if self.run_once:
return

View File

@@ -1,14 +1,7 @@
import curses
from django_q.brokers import get_broker
from django_q.models import Failure, Success
from django_q.conf import Conf
from django_q.status import Stat
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils import timezone
import time
import signal from django_q.monitor_terminal import monitor
class Command(BaseCommand): class Command(BaseCommand):
@@ -25,109 +18,4 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
memory_stats = MonitorTerminalStats( monitor(run_once=options.get("run_once", False))
run_once=options.get("run_once", False),
)
curses.wrapper(memory_stats.start)
class MonitorTerminalStats:
stop_writing = False
table_cell_size = 20
def __init__(self, run_once=False):
self.run_once = run_once
def start(self, stdscr):
self.show_stats()
def on_exit(self, signum, frame):
# exit clean
self.stop_writing = True
def get_table_cell(self, data):
spaces = self.table_cell_size - len(str(data))
return data + " " * spaces + "// "
def show_stats(self):
signal.signal(signal.SIGTERM, self.on_exit)
signal.signal(signal.SIGINT, self.on_exit)
scr = curses.initscr()
broker = get_broker()
broker.ping()
stats = Stat.get_all(broker=broker)
if not stats:
scr.addstr(1, 0, "Cluster is not running")
scr.refresh()
while not self.stop_writing:
data = []
table_headers = [
_("Host"),
_("Id"),
_("State"),
_("Pool"),
_("TQ"),
_("RQ"),
_("RC"),
_("Up"),
]
data.append("".join([self.get_table_cell(header) for header in table_headers]))
for stat in stats:
tasks = str(stat.task_q_size)
if stat.task_q_size > 0:
tasks = str(stat.task_q_size)
if Conf.QUEUE_LIMIT and stat.task_q_size == Conf.QUEUE_LIMIT:
tasks += " (at maximum size)"
results = stat.done_q_size
if results > 0:
results = str(results)
# color workers
workers = len(stat.workers)
# format uptime
uptime = (timezone.now() - stat.tob).total_seconds()
hours, remainder = divmod(uptime, 3600)
minutes, seconds = divmod(remainder, 60)
uptime = "%d:%02d:%02d" % (hours, minutes, seconds)
# print to the terminal
stat_values = [
str(stat.host),
str(stat.cluster_id)[-8:],
str(stat.status),
str(workers),
str(tasks),
str(results),
str(stat.reincarnations),
str(uptime),
]
data.append("".join([self.get_table_cell(val) for val in stat_values]))
data.append("")
queue_size = broker.queue_size()
lock_size = broker.lock_size()
if lock_size:
queue_size = f"{queue_size}({lock_size})"
data.append("")
data.append(_("info: %(broker_info)s") % {"broker_info": broker.info()})
data.append("")
data.append(_("Queued: %(queue_size)s") % {"queue_size": str(broker.queue_size())})
data.append(_("Successes: %(success_count)s") % {"success_count": str(Success.objects.count())})
data.append(_("Failures: %(failure_count)s") % {"failure_count": str(Failure.objects.count())})
for idx, item in enumerate(data):
scr.addstr(idx, 0, item)
scr.refresh()
time.sleep(0.5)
if self.run_once:
return

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [] dependencies = []
operations = [ operations = [

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0001_initial"), ("django_q", "0001_initial"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0002_auto_20150630_1624"), ("django_q", "0002_auto_20150630_1624"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0003_auto_20150708_1326"), ("django_q", "0003_auto_20150708_1326"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0004_auto_20150710_1043"), ("django_q", "0004_auto_20150710_1043"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0005_auto_20150718_1506"), ("django_q", "0005_auto_20150718_1506"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0006_auto_20150805_1817"), ("django_q", "0006_auto_20150805_1817"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0007_ormq"), ("django_q", "0007_ormq"),
] ]

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0008_auto_20160224_1026"), ("django_q", "0008_auto_20160224_1026"),
] ]

View File

@@ -3,7 +3,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0009_auto_20171009_0915"), ("django_q", "0009_auto_20171009_0915"),
] ]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0010_auto_20200610_0856"), ("django_q", "0010_auto_20200610_0856"),
] ]

View File

@@ -6,7 +6,6 @@ import django_q.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0011_auto_20200628_1055"), ("django_q", "0011_auto_20200628_1055"),
] ]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0012_auto_20200702_1608"), ("django_q", "0012_auto_20200702_1608"),
] ]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0013_task_attempt_count"), ("django_q", "0013_task_attempt_count"),
] ]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0014_schedule_cluster"), ("django_q", "0014_schedule_cluster"),
] ]

View File

@@ -1,11 +1,11 @@
# Generated by Django 4.1.2 on 2023-01-15 22:34 # Generated by Django 4.1.2 on 2023-01-15 22:34
from django.db import migrations, models from django.db import migrations, models
import django_q.models import django_q.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0015_alter_schedule_schedule_type"), ("django_q", "0015_alter_schedule_schedule_type"),
] ]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_q", "0016_schedule_intended_date_kwarg"), ("django_q", "0016_schedule_intended_date_kwarg"),
] ]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2024-03-05 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_q", "0017_task_cluster_alter"),
]
operations = [
migrations.AddIndex(
model_name="task",
index=models.Index(
condition=models.Q(("success", True)),
fields=["group", "name", "func"],
name="success_index",
),
),
]

View File

@@ -1,27 +1,28 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from keyword import iskeyword from keyword import iskeyword
import ast
# Django # Django
from django import get_version from django import get_version
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q
from django.template.defaultfilters import truncatechars from django.template.defaultfilters import truncatechars
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import is_aware from django.utils.functional import cached_property
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
# External # External
from picklefield import PickledObjectField from picklefield import PickledObjectField
from picklefield.fields import dbsafe_decode from picklefield.fields import dbsafe_decode
# Local # Local
from django_q.conf import croniter, Conf from django_q.conf import croniter
from django_q.signing import SignedPackage from django_q.signing import SignedPackage
from django_q.utils import localtime, add_months, add_years from django_q.utils import add_months, add_years, localtime
from .utils import get_func_repr
class Task(models.Model): class Task(models.Model):
@@ -111,6 +112,13 @@ class Task(models.Model):
class Meta: class Meta:
app_label = "django_q" app_label = "django_q"
ordering = ["-stopped"] ordering = ["-stopped"]
indexes = [
models.Index(
name="success_index",
fields=["group", "name", "func"],
condition=Q(success=True),
),
]
class SuccessManager(models.Manager): class SuccessManager(models.Manager):
@@ -217,8 +225,11 @@ class Schedule(models.Model):
) )
task = models.CharField(max_length=100, null=True, editable=False) task = models.CharField(max_length=100, null=True, editable=False)
cluster = models.CharField( cluster = models.CharField(
max_length=100, default=None, null=True, blank=True, max_length=100,
help_text=_("Name of the target cluster") default=None,
null=True,
blank=True,
help_text=_("Name of the target cluster"),
) )
intended_date_kwarg = models.CharField( intended_date_kwarg = models.CharField(
max_length=100, max_length=100,
@@ -228,34 +239,6 @@ class Schedule(models.Model):
help_text=_("Name of kwarg to pass intended schedule date"), help_text=_("Name of kwarg to pass intended schedule date"),
) )
def parse_kwargs(self):
if not self.kwargs:
return {}
try:
# first try the dict syntax
return ast.literal_eval(self.kwargs)
except (SyntaxError, ValueError):
# else use the kwargs syntax
try:
parsed_kwargs = (
ast.parse(f"f({self.kwargs})").body[0].value.keywords
)
return {
kwarg.arg: ast.literal_eval(kwarg.value)
for kwarg in parsed_kwargs
}
except (SyntaxError, ValueError):
return {}
def parse_args(self):
if not self.args:
return tuple()
args = ast.literal_eval(self.args)
# single value won't eval to tuple, so:
if type(args) != tuple:
args = (args,)
return args
def calculate_next_run(self, next_run=None): def calculate_next_run(self, next_run=None):
# next run is always in UTC # next run is always in UTC
next_run = next_run or self.next_run next_run = next_run or self.next_run
@@ -336,8 +319,9 @@ class Schedule(models.Model):
class OrmQ(models.Model): class OrmQ(models.Model):
key = models.CharField(max_length=100, help_text=_("Name of the target cluster")) key = models.CharField(max_length=100, help_text=_("Name of the target cluster"))
payload = models.TextField() payload = models.TextField()
lock = models.DateTimeField(null=True, help_text=_("Prevent any cluster from pulling until")) lock = models.DateTimeField(
null=True, help_text=_("Prevent any cluster from pulling until")
)
@cached_property @cached_property
def task(self): def task(self):
@@ -347,24 +331,16 @@ class OrmQ(models.Model):
return {"id": "*" + e.__class__.__name__} return {"id": "*" + e.__class__.__name__}
def func(self): def func(self):
if isinstance(self.task, dict): return get_func_repr(self.task.get("func"))
return self.task.get("func_name", "")
return self.task.func_name
def task_id(self): def task_id(self):
if isinstance(self.task, dict): return self.task.get("id")
return self.task.get("id", "")
return self.task.id
def name(self): def name(self):
if isinstance(self.task, dict): return self.task.get("name")
return self.task["name"]
return self.task.name
def group(self): def group(self):
if isinstance(self.task, dict): return self.task.get("group")
return self.task.get("group", "")
return self.task.group
def args(self): def args(self):
return self.task.get("args") return self.task.get("args")

View File

@@ -1,15 +1,24 @@
from django_q.worker import WorkerProcess from multiprocessing.process import current_process
from django_q.queue_task import QueueTask from multiprocessing.queues import Queue
from django_q.models import Task
from queue import Queue from django import core, db
from queue import Empty from django.apps.registry import apps
from typing import Optional, Tuple
from django_q.brokers import get_broker
from django_q.process_manager import ProcessManager
from django_q.signals import post_execute
from django_q.conf import logger
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from multiprocessing import current_process
try:
apps.check_apps_ready()
except core.exceptions.AppRegistryNotReady:
import django
django.setup()
from django_q.brokers import Broker, get_broker
from django_q.conf import Conf, logger, setproctitle
from django_q.models import Success, Task
from django_q.signals import post_execute
from django_q.signing import SignedPackage
from django_q.tasks import async_chain
from django_q.utils import close_old_django_connections, get_func_repr
try: try:
import setproctitle import setproctitle
@@ -17,86 +26,179 @@ except ModuleNotFoundError:
setproctitle = None setproctitle = None
class Monitor(ProcessManager): def monitor(result_queue: Queue, broker: Broker = None):
def __init__(self): """
super().__init__() Gets finished tasks from the result queue and saves them to Django
self.task_queue = Queue() :type broker: brokers.Broker
:type result_queue: multiprocessing.Queue
@staticmethod """
def save_task(task, broker=None) -> Tuple[QueueTask, Optional[Task]]: if not broker:
task_db_obj = None broker = get_broker()
if broker is None: proc_name = current_process().name
broker = get_broker() if setproctitle:
if task.cached: setproctitle.setproctitle(f"qcluster {proc_name} monitor")
task.save_cached(broker) logger.info(
_("%(name)s monitoring at %(id)s")
% {"name": proc_name, "id": current_process().pid}
)
for task in iter(result_queue.get, "STOP"):
# save the result
if task.get("cached", False):
save_cached(task, broker)
else: else:
print("SAVE TO DB") save_task(task, broker)
task_db_obj = task.save_to_db(broker)
# acknowledge result # acknowledge result
if task.ack_id and (task.has_succeeded or not task.ack_failure): ack_id = task.pop("ack_id", False)
broker.acknowledge(task.ack_id) if ack_id and (task["success"] or task.get("ack_failure", False)):
broker.acknowledge(ack_id)
# signal execution done # signal execution done
post_execute.send(sender="django_q", task=task) post_execute.send(sender="django_q", task=task)
return task, task_db_obj # log the result
info_name = get_func_repr(task["func"])
if task["success"]:
# log success
logger.info(
_("Processed '%(info_name)s' (%(task_name)s)")
% {"info_name": info_name, "task_name": task["name"]}
)
else:
# log failure
logger.error(
_("Failed '%(info_name)s' (%(task_name)s) - %(task_result)s")
% {
"info_name": info_name,
"task_name": task["name"],
"task_result": task["result"],
}
)
logger.info(_("%(name)s stopped monitoring results") % {"name": proc_name})
@property def save_task(task, broker: Broker):
def is_done(self): """
return self.status.value == self.Status.IDLE.value and self.task_queue.empty() Saves the task package to Django or the cache
:param task: the task package
:type broker: brokers.Broker
"""
# SAVE LIMIT < 0 : Don't save success
if not task.get("save", Conf.SAVE_LIMIT >= 0) and task["success"]:
return
# enqueues next in a chain
if task.get("chain", None):
async_chain(
task["chain"],
group=task["group"],
cached=task["cached"],
sync=task["sync"],
broker=broker,
)
# SAVE LIMIT > 0: Prune database, SAVE_LIMIT 0: No pruning
close_old_django_connections()
def get_target(self): try:
return self.run_monitor filters = {}
if (
Conf.SAVE_LIMIT_PER
and Conf.SAVE_LIMIT_PER in {"group", "name", "func"}
and Conf.SAVE_LIMIT_PER in task
):
value = task[Conf.SAVE_LIMIT_PER]
if Conf.SAVE_LIMIT_PER == "func":
value = get_func_repr(value)
filters[Conf.SAVE_LIMIT_PER] = value
def run_item(self): with db.transaction.atomic(using=db.router.db_for_write(Success)):
if self.is_idle: list(Success.objects.filter(**filters).select_for_update())
try: if (
task = self.task_queue.get_nowait() task["success"]
except Empty: and 0 < Conf.SAVE_LIMIT <= Success.objects.filter(**filters).count()
# if the queue is empty, then just stop ):
Success.objects.filter(**filters).last().delete()
# check if this task has previous results
try:
task_obj = Task.objects.get(id=task["id"], name=task["name"])
# only update the result if it hasn't succeeded yet
if not task_obj.success:
task_obj.stopped = task["stopped"]
task_obj.result = task["result"]
task_obj.success = task["success"]
task_obj.attempt_count = task_obj.attempt_count + 1
task_obj.save()
except Task.DoesNotExist:
# convert func to string
func = get_func_repr(task["func"])
task_obj = Task.objects.create(
id=task["id"],
name=task["name"],
func=func,
hook=task.get("hook"),
args=task["args"],
kwargs=task["kwargs"],
cluster=task.get("cluster"),
started=task["started"],
stopped=task["stopped"],
result=task["result"],
group=task.get("group"),
success=task["success"],
attempt_count=1,
)
if Conf.MAX_ATTEMPTS > 0 and task_obj.attempt_count >= Conf.MAX_ATTEMPTS:
broker.acknowledge(task["ack_id"])
except Exception:
logger.exception("Could not save task result")
def save_cached(task, broker: Broker):
task_key = f'{broker.list_key}:{task["id"]}'
timeout = task["cached"]
if timeout is True:
timeout = None
try:
group = task.get("group", None)
iter_count = task.get("iter_count", 0)
# if it's a group append to the group list
if group:
group_key = f"{broker.list_key}:{group}:keys"
group_list = broker.cache.get(group_key) or []
# if it's an iter group, check if we are ready
if iter_count and len(group_list) == iter_count - 1:
group_args = f"{broker.list_key}:{group}:args"
# collate the results into a Task result
results = [
SignedPackage.loads(broker.cache.get(k))["result"]
for k in group_list
]
results.append(task["result"])
task["result"] = results
task["id"] = group
task["args"] = SignedPackage.loads(broker.cache.get(group_args))
task.pop("iter_count", None)
task.pop("group", None)
if task.get("iter_cached", None):
task["cached"] = task.pop("iter_cached", None)
save_cached(task, broker=broker)
else:
save_task(task, broker)
broker.cache.delete_many(group_list)
broker.cache.delete_many([group_key, group_args])
return return
try: # save the group list
self.manager_pipe.send(task) group_list.append(task_key)
except BrokenPipeError: broker.cache.set(group_key, group_list, timeout)
# recycle process if pipe is broken # async_task next in a chain
self.status.value = ProcessManager.Status.RECYCLE.value if task.get("chain", None):
async_chain(
def add_task(self, task): task["chain"],
self.task_queue.put(task) group=group,
cached=task["cached"],
def run_monitor(self, status, pipe) -> None: sync=task["sync"],
broker = get_broker() broker=broker,
proc_name = current_process().name
if setproctitle:
setproctitle.setproctitle(f"qcluster {proc_name} monitor")
logger.info(
_("%(name)s monitoring at %(id)s") % {"name": proc_name, "id": current_process().pid}
) )
status.value = self.Status.IDLE.value # save the task
broker.cache.set(task_key, SignedPackage.dumps(task), timeout)
while True: except Exception:
task = pipe.recv() logger.exception("Could not save task result")
if task == "STOP":
logger.info(f"Monitor {proc_name} shut down")
break
status.value = self.Status.BUSY.value
# save the result
task, __ = Monitor.save_task(task, broker=broker)
# log the result
if task.has_succeeded:
# log success
logger.info(
_("Processed '%(info_name)s' (%(task_name)s)")
% {"info_name": task.func_name, "task_name": task.name}
)
else:
# log failure
logger.error(
_("Failed '%(info_name)s' (%(task_name)s) - %(task_result)s")
% {
"info_name": task.func_name,
"task_name": task.name,
"task_result": task.result,
}
)
status.value = self.Status.IDLE.value
logger.info(_("%(name)s stopped monitoring results") % {"name": proc_name})

View File

@@ -0,0 +1,508 @@
from datetime import timedelta
# django
from django.db import connection
from django.db.models import F, Sum
from django.utils import timezone
from django.utils.translation import gettext as _
from django_q import VERSION, models
from django_q.brokers import get_broker
# local
from django_q.conf import Conf
from django_q.status import Stat
# optional
try:
import psutil
except ImportError:
psutil = None
def get_process_mb(pid):
try:
process = psutil.Process(pid)
mb_used = round(process.memory_info().rss / 1024**2, 2)
except psutil.NoSuchProcess:
mb_used = "NO_PROCESS_FOUND"
return mb_used
BLESSED_INSTALL_MESSAGE = (
"Blessed is not installed. Please install blessed to use this: "
"https://pypi.org/project/blessed/"
)
def monitor(run_once=False, broker=None):
if not broker:
broker = get_broker()
try:
from blessed import Terminal
term = Terminal()
except ImportError:
print(BLESSED_INSTALL_MESSAGE)
return
broker.ping()
with term.fullscreen(), term.hidden_cursor(), term.cbreak():
val = None
start_width = int(term.width / 8)
while val not in (
"q",
"Q",
):
col_width = int(term.width / 8)
# In case of resize
if col_width != start_width:
print(term.clear())
start_width = col_width
print(
term.move(0, 0)
+ term.black_on_green(term.center(_("Host"), width=col_width - 1))
)
print(
term.move(0, 1 * col_width)
+ term.black_on_green(term.center(_("Id"), width=col_width - 1))
)
print(
term.move(0, 2 * col_width)
+ term.black_on_green(term.center(_("State"), width=col_width - 1))
)
print(
term.move(0, 3 * col_width)
+ term.black_on_green(term.center(_("Pool"), width=col_width - 1))
)
print(
term.move(0, 4 * col_width)
+ term.black_on_green(term.center(_("TQ"), width=col_width - 1))
)
print(
term.move(0, 5 * col_width)
+ term.black_on_green(term.center(_("RQ"), width=col_width - 1))
)
print(
term.move(0, 6 * col_width)
+ term.black_on_green(term.center(_("RC"), width=col_width - 1))
)
print(
term.move(0, 7 * col_width)
+ term.black_on_green(term.center(_("Up"), width=col_width - 1))
)
i = 2
stats = Stat.get_all(broker=broker)
print(term.clear_eos())
for stat in stats:
status = stat.status
# color status
if stat.status == Conf.WORKING:
status = term.green(str(Conf.WORKING))
elif stat.status == Conf.STOPPING:
status = term.yellow(str(Conf.STOPPING))
elif stat.status == Conf.STOPPED:
status = term.red(str(Conf.STOPPED))
elif stat.status == Conf.IDLE:
status = str(Conf.IDLE)
# color q's
tasks = str(stat.task_q_size)
if stat.task_q_size > 0:
tasks = term.cyan(str(stat.task_q_size))
if Conf.QUEUE_LIMIT and stat.task_q_size == Conf.QUEUE_LIMIT:
tasks = term.green(str(stat.task_q_size))
results = stat.done_q_size
if results > 0:
results = term.cyan(str(results))
# color workers
workers = len(stat.workers)
if workers < Conf.WORKERS:
workers = term.yellow(str(workers))
# format uptime
uptime = (timezone.now() - stat.tob).total_seconds()
hours, remainder = divmod(uptime, 3600)
minutes, seconds = divmod(remainder, 60)
uptime = "%d:%02d:%02d" % (hours, minutes, seconds)
# print to the terminal
print(
term.move(i, 0)
+ term.center(stat.host[: col_width - 1], width=col_width - 1)
)
print(
term.move(i, 1 * col_width)
+ term.center(str(stat.cluster_id)[-8:], width=col_width - 1)
)
print(
term.move(i, 2 * col_width)
+ term.center(status, width=col_width - 1)
)
print(
term.move(i, 3 * col_width)
+ term.center(workers, width=col_width - 1)
)
print(
term.move(i, 4 * col_width)
+ term.center(tasks, width=col_width - 1)
)
print(
term.move(i, 5 * col_width)
+ term.center(results, width=col_width - 1)
)
print(
term.move(i, 6 * col_width)
+ term.center(stat.reincarnations, width=col_width - 1)
)
print(
term.move(i, 7 * col_width)
+ term.center(uptime, width=col_width - 1)
)
i += 1
# bottom bar
i += 1
queue_size = broker.queue_size()
lock_size = broker.lock_size()
if lock_size:
queue_size = f"{queue_size}({lock_size})"
print(
term.move(i, 0)
+ term.white_on_cyan(term.center(broker.info(), width=col_width * 2))
)
print(
term.move(i, 2 * col_width)
+ term.black_on_cyan(term.center(_("Queued"), width=col_width))
)
print(
term.move(i, 3 * col_width)
+ term.white_on_cyan(term.center(queue_size, width=col_width))
)
print(
term.move(i, 4 * col_width)
+ term.black_on_cyan(term.center(_("Success"), width=col_width))
)
print(
term.move(i, 5 * col_width)
+ term.white_on_cyan(
term.center(models.Success.objects.count(), width=col_width)
)
)
print(
term.move(i, 6 * col_width)
+ term.black_on_cyan(term.center(_("Failures"), width=col_width))
)
print(
term.move(i, 7 * col_width)
+ term.white_on_cyan(
term.center(models.Failure.objects.count(), width=col_width)
)
)
# for testing
if run_once:
return Stat.get_all(broker=broker)
print(term.move(i + 2, 0) + term.center(_("[Press q to quit]")))
val = term.inkey(timeout=1)
def info(broker=None):
if not broker:
broker = get_broker()
try:
from blessed import Terminal
term = Terminal()
except ImportError:
print(BLESSED_INSTALL_MESSAGE)
return
broker.ping()
stat = Stat.get_all(broker=broker)
# general stats
clusters = len(stat)
workers = 0
reincarnations = 0
for cluster in stat:
workers += len(cluster.workers)
reincarnations += cluster.reincarnations
# calculate tasks pm and avg exec time
tasks_per = 0
per = _("day")
exec_time = 0
last_tasks = models.Success.objects.filter(
stopped__gte=timezone.now() - timedelta(hours=24)
)
tasks_per_day = last_tasks.count()
if tasks_per_day > 0:
# average execution time over the last 24 hours
if connection.vendor != "sqlite":
exec_time = last_tasks.aggregate(
time_taken=Sum(F("stopped") - F("started"))
)
exec_time = exec_time["time_taken"].total_seconds() / tasks_per_day
else:
# can't sum timedeltas on sqlite
for t in last_tasks:
exec_time += t.time_taken()
exec_time = exec_time / tasks_per_day
# tasks per second/minute/hour/day in the last 24 hours
if tasks_per_day > 24 * 60 * 60:
tasks_per = tasks_per_day / (24 * 60 * 60)
per = _("second")
elif tasks_per_day > 24 * 60:
tasks_per = tasks_per_day / (24 * 60)
per = _("minute")
elif tasks_per_day > 24:
tasks_per = tasks_per_day / 24
per = _("hour")
else:
tasks_per = tasks_per_day
# print to terminal
print(term.clear_eos())
col_width = int(term.width / 6)
print(
term.black_on_green(
term.center(
_("-- %(prefix)s %(version)s on %(info)s --")
% {
"prefix": Conf.PREFIX.capitalize(),
"version": ".".join(str(v) for v in VERSION),
"info": broker.info(),
}
)
)
)
print(
term.cyan(_("Clusters"))
+ term.move_x(1 * col_width)
+ term.white(str(clusters))
+ term.move_x(2 * col_width)
+ term.cyan(_("Workers"))
+ term.move_x(3 * col_width)
+ term.white(str(workers))
+ term.move_x(4 * col_width)
+ term.cyan(_("Restarts"))
+ term.move_x(5 * col_width)
+ term.white(str(reincarnations))
)
print(
term.cyan(_("Queued"))
+ term.move_x(1 * col_width)
+ term.white(str(broker.queue_size()))
+ term.move_x(2 * col_width)
+ term.cyan(_("Successes"))
+ term.move_x(3 * col_width)
+ term.white(str(models.Success.objects.count()))
+ term.move_x(4 * col_width)
+ term.cyan(_("Failures"))
+ term.move_x(5 * col_width)
+ term.white(str(models.Failure.objects.count()))
)
print(
term.cyan(_("Schedules"))
+ term.move_x(1 * col_width)
+ term.white(str(models.Schedule.objects.count()))
+ term.move_x(2 * col_width)
+ term.cyan(_("Tasks/%(per)s") % {"per": per})
+ term.move_x(3 * col_width)
+ term.white(f"{tasks_per:.2f}")
+ term.move_x(4 * col_width)
+ term.cyan(_("Avg time"))
+ term.move_x(5 * col_width)
+ term.white(f"{exec_time:.4f}")
)
return True
def memory(run_once=False, workers=False, broker=None):
if not broker:
broker = get_broker()
try:
from blessed import Terminal
term = Terminal()
except ImportError:
print(BLESSED_INSTALL_MESSAGE)
return
broker.ping()
if not psutil:
print(term.clear_eos())
print(
term.white_on_red(
'Cannot start "qmemory" command. Missing "psutil" library.'
)
)
return
with term.fullscreen(), term.hidden_cursor(), term.cbreak():
MEMORY_AVAILABLE_LOWEST_PERCENTAGE = 100.0
MEMORY_AVAILABLE_LOWEST_PERCENTAGE_AT = timezone.now()
cols = 8
val = None
start_width = int(term.width / cols)
while val not in ["q", "Q"]:
col_width = int(term.width / cols)
# In case of resize
if col_width != start_width:
print(term.clear())
start_width = col_width
# sentinel, monitor and workers memory usage
print(
term.move(0, 0 * col_width)
+ term.black_on_green(term.center(_("Host"), width=col_width - 1))
)
print(
term.move(0, 1 * col_width)
+ term.black_on_green(term.center(_("Id"), width=col_width - 1))
)
print(
term.move(0, 2 * col_width)
+ term.black_on_green(
term.center(_("Available (%)"), width=col_width - 1)
)
)
print(
term.move(0, 3 * col_width)
+ term.black_on_green(
term.center(_("Available (MB)"), width=col_width - 1)
)
)
print(
term.move(0, 4 * col_width)
+ term.black_on_green(term.center(_("Total (MB)"), width=col_width - 1))
)
print(
term.move(0, 5 * col_width)
+ term.black_on_green(
term.center(_("Sentinel (MB)"), width=col_width - 1)
)
)
print(
term.move(0, 6 * col_width)
+ term.black_on_green(
term.center(_("Monitor (MB)"), width=col_width - 1)
)
)
print(
term.move(0, 7 * col_width)
+ term.black_on_green(
term.center(_("Workers (MB)"), width=col_width - 1)
)
)
row = 2
stats = Stat.get_all(broker=broker)
print(term.clear_eos())
for stat in stats:
# memory available (%)
memory_available_percentage = round(
psutil.virtual_memory().available
* 100
/ psutil.virtual_memory().total,
2,
)
# memory available (MB)
memory_available = round(psutil.virtual_memory().available / 1024**2, 2)
if memory_available_percentage < MEMORY_AVAILABLE_LOWEST_PERCENTAGE:
MEMORY_AVAILABLE_LOWEST_PERCENTAGE = memory_available_percentage
MEMORY_AVAILABLE_LOWEST_PERCENTAGE_AT = timezone.now()
print(
term.move(row, 0 * col_width)
+ term.center(stat.host[: col_width - 1], width=col_width - 1)
)
print(
term.move(row, 1 * col_width)
+ term.center(str(stat.cluster_id)[-8:], width=col_width - 1)
)
print(
term.move(row, 2 * col_width)
+ term.center(memory_available_percentage, width=col_width - 1)
)
print(
term.move(row, 3 * col_width)
+ term.center(memory_available, width=col_width - 1)
)
print(
term.move(row, 4 * col_width)
+ term.center(
round(psutil.virtual_memory().total / 1024**2, 2),
width=col_width - 1,
)
)
print(
term.move(row, 5 * col_width)
+ term.center(get_process_mb(stat.sentinel), width=col_width - 1)
)
print(
term.move(row, 6 * col_width)
+ term.center(
get_process_mb(getattr(stat, "monitor", None)),
width=col_width - 1,
)
)
workers_mb = 0
for worker_pid in stat.workers:
result = get_process_mb(worker_pid)
if isinstance(result, str):
result = 0
workers_mb += result
print(
term.move(row, 7 * col_width)
+ term.center(
workers_mb or "NO_PROCESSES_FOUND", width=col_width - 1
)
)
row += 1
# each worker's memory usage
if workers:
row += 2
col_width = int(term.width / (1 + Conf.WORKERS))
print(
term.move(row, 0 * col_width)
+ term.black_on_cyan(term.center(_("Id"), width=col_width - 1))
)
for worker_num in range(Conf.WORKERS):
print(
term.move(row, (worker_num + 1) * col_width)
+ term.black_on_cyan(
term.center(
"Worker #{} (MB)".format(worker_num + 1),
width=col_width - 1,
)
)
)
row += 2
for stat in stats:
print(
term.move(row, 0 * col_width)
+ term.center(str(stat.cluster_id)[-8:], width=col_width - 1)
)
for idx, worker_pid in enumerate(stat.workers):
mb_used = get_process_mb(worker_pid)
print(
term.move(row, (idx + 1) * col_width)
+ term.center(mb_used, width=col_width - 1)
)
row += 1
row += 1
print(
term.move(row, 0)
+ _("Available lowest (): %(memory_percent)s ((at)s)")
% {
"memory_percent": str(MEMORY_AVAILABLE_LOWEST_PERCENTAGE),
"at": MEMORY_AVAILABLE_LOWEST_PERCENTAGE_AT.strftime(
"%Y-%m-%d %H:%M:%S+00:00"
),
}
)
# for testing
if run_once:
return Stat.get_all(broker=broker)
print(term.move(row + 2, 0) + term.center(_("[Press q to quit]")))
val = term.inkey(timeout=1)
def get_ids():
# prints id (PID) of running clusters
stat = Stat.get_all()
if stat:
for s in stat:
print(s.cluster_id)
else:
print(_("No clusters appear to be running."))
return True

View File

@@ -1,75 +0,0 @@
from abc import ABC
from django_q.conf import Conf, logger
from django_q.humanhash import humanize
import uuid
from django_q.conf import Conf
import enum
from django import db
from typing import Callable
from django.utils.translation import gettext_lazy as _
import multiprocessing
from multiprocessing import Process, Value
class ProcessManager(ABC):
class Status(enum.IntEnum):
IDLE = 1
BUSY = 2
DONE = 3
RECYCLE = 4
target = None
def get_target(self) -> Callable:
if self.target is None:
raise ValueError("Process must have target specified")
return self.target
def __init__(self):
self.status = Value("i", self.Status.IDLE.value)
self.process = self.spawn_process()
self.name = humanize(uuid.uuid4().hex)
def spawn_process(self) -> Process:
self.manager_pipe, process_pipe = multiprocessing.Pipe(duplex=True)
p = Process(target=self.get_target(), args=(self.status, process_pipe))
p.start()
return p
def reincarnate_process(self):
# kill connections before killing the process
logger.critical(_("reincarnated worker %(name)s after death") % {"name": self.process.name})
if not Conf.SYNC:
db.connections.close_all()
self.process.kill()
self.process = self.spawn_process()
self.mark_idle()
@property
def has_results(self):
# poll puller pipe for new tasks
return self.manager_pipe.poll()
def get_result(self):
# get the puller pipe task object back from the worker
return self.manager_pipe.recv()
@property
def is_alive(self):
# get the puller status
return self.process.is_alive()
@property
def is_done(self):
return self.status.value == self.Status.DONE.value
@property
def is_idle(self):
return self.status.value == self.Status.IDLE.value
@property
def is_recycle(self):
# worker needs to be recycled/reincarnated
return self.status.value == self.Status.RECYCLE.value
def mark_idle(self):
self.status.value = self.Status.IDLE.value

View File

@@ -1,85 +0,0 @@
from django_q.worker import Worker
from django_q.signing import BadSignature, SignedPackage
from time import sleep
from django_q.brokers import get_broker
import multiprocessing
from django_q.queue_task import QueueTask
from django.utils import timezone
import enum
import traceback
from multiprocessing import Event, Process, Value, current_process
from django_q.utils import close_old_django_connections
from django.utils.translation import gettext_lazy as _
from django_q.conf import Conf, logger, setproctitle, error_reporter, resource, psutil
from django_q.exceptions import TimeoutException, TimeoutHandler
from django_q.process_manager import ProcessManager
class Puller(ProcessManager):
"""The Puller is responsible for pulling the tasks from the broker, then return them to be picked up by the
guard"""
@staticmethod
def get_tasks_from_broker(broker=None):
queued_tasks = []
if broker is None:
broker = get_broker()
try:
task_set = broker.dequeue()
except Exception:
# broker probably crashed. Let the sentinel handle it.
raise ValueError("Failed to pull task from broker")
if task_set:
logger.info(
_("Found %(amount_tasks)s tasks") % {"amount_tasks": len(task_set)}
)
for task in task_set:
print(task)
logger.info("ONE TASK")
ack_id = task[0]
# unpack the task
try:
queue_task = SignedPackage.loads(task[1])
except (TypeError, BadSignature):
logger.exception("Failed to pull task from broker - bad task")
broker.fail(ack_id)
continue
queue_task.cluster = Conf.CLUSTER_NAME # save actual cluster name to orm task table
queue_task.ack_id = ack_id
# send back to main process
queued_tasks.append(queue_task)
logger.debug(
_("queueing from %(list_key)s") % {"list_key": broker.list_key}
)
return queued_tasks
def get_target(self):
return self.run_puller
def stop_puller(self):
self.status.value = self.Status.DONE.value
def run_puller(self, status, pipe) -> None:
broker = get_broker()
proc_name = current_process().name
if setproctitle:
setproctitle.setproctitle(f"qcluster {proc_name} puller")
logger.info(
_("%(name)s pulling tasks from broker %(id)s")
% {"name": proc_name, "id": current_process().pid}
)
while True:
if status.value == Worker.Status.DONE.value:
logger.info("Stopping Puller")
break
try:
queued_tasks = Puller.get_tasks_from_broker(broker=broker)
except Exception:
logger.exception("Couldn't get items from broker")
sleep(10)
break
for queue_task in queued_tasks:
pipe.send(queue_task)
logger.info(_("%(name)s stopped pushing tasks") % {"name": current_process().name})

71
django_q/pusher.py Normal file
View File

@@ -0,0 +1,71 @@
from multiprocessing import Event
from multiprocessing.process import current_process
from multiprocessing.queues import Queue
from time import sleep
from django import core
from django.apps.registry import apps
from django.utils.translation import gettext_lazy as _
try:
apps.check_apps_ready()
except core.exceptions.AppRegistryNotReady:
import django
django.setup()
from django_q.brokers import Broker, get_broker
from django_q.conf import Conf, logger
from django_q.signing import BadSignature, SignedPackage
try:
import setproctitle
except ModuleNotFoundError:
setproctitle = None
def pusher(task_queue: Queue, event: Event, broker: Broker = None):
"""
Pulls tasks of the broker and puts them in the task queue
:type broker:
:type task_queue: multiprocessing.Queue
:type event: multiprocessing.Event
"""
if not broker:
broker = get_broker()
proc_name = current_process().name
if setproctitle:
setproctitle.setproctitle(f"qcluster {proc_name} pusher")
logger.info(
_("%(name)s pushing tasks at %(id)s")
% {"name": proc_name, "id": current_process().pid}
)
while True:
try:
task_set = broker.dequeue()
except Exception:
logger.exception("Failed to pull task from broker")
# broker probably crashed. Let the sentinel handle it.
sleep(10)
break
if task_set:
for task in task_set:
ack_id = task[0]
# unpack the task
try:
task = SignedPackage.loads(task[1])
except (TypeError, BadSignature):
logger.exception("Failed to push task to queue")
broker.fail(ack_id)
continue
task["cluster"] = (
Conf.CLUSTER_NAME
) # save actual cluster name to orm task table
task["ack_id"] = ack_id
task_queue.put(task)
logger.debug(
_("queueing from %(list_key)s") % {"list_key": broker.list_key}
)
if event.is_set():
break
logger.info(_("%(name)s stopped pushing tasks") % {"name": current_process().name})

View File

@@ -1,211 +0,0 @@
from __future__ import annotations
from datetime import datetime
from django_q.brokers import Broker
from django.utils import timezone
from django_q.signing import SignedPackage
from django_q.models import Success, Task
from django_q.utils import close_old_django_connections
import enum
from django_q import tasks
import inspect
import pydoc
from django import db
from dataclasses import dataclass, field
from typing import Any, Callable, Optional, Union
from django_q.conf import Conf, logger
@dataclass
class QueueTask:
class Status(enum.IntEnum):
QUEUED = 0
SUCCESS = 1
FAILED = 2
TIMEOUT = 3
func: Union[Callable, str]
name: str
group: Optional[str] = None
cluster: str = Conf.CLUSTER_NAME
queued_at: Optional[datetime] = timezone.now()
finished_at: Optional[datetime] = None
ack_id: Optional[str] = None
started_at: Optional[datetime] = None
id: str = "-1"
timeout: Optional[int] = Conf.TIMEOUT
status: Optional[Status] = None
result: Any = None
save: bool = Conf.SAVE_LIMIT >= 0
chain: Union[str, QueueTask] = ""
cached: bool = Conf.CACHED
sync: bool = Conf.SYNC
hook: Optional[str] = None
args: tuple = field(default_factory=tuple)
kwargs: dict = field(default_factory=dict)
ack_failure: bool = Conf.ACK_FAILURES
iter_count: Optional[int] = None
iter_cached: Optional[int] = None
def callable_func(self):
func = self.func
if not callable(func):
func = pydoc.locate(func)
return func
@property
def has_succeeded(self):
return self.status == self.Status.SUCCESS
@property
def has_timed_out(self):
return self.status == self.Status.TIMEOUT
@property
def is_callable(self):
return self.callable_func is not None
@property
def func_name(self):
if inspect.isfunction(self.func):
return f"{self.func.__module__}.{self.func.__name__}"
elif inspect.ismethod(self.func) and hasattr(self.func.__self__, "__name__"):
return (
f"{self.func.__self__.__module__}." f"{self.func.__self__.__name__}.{self.func.__name__}"
)
else:
return str(self.func)
def save_to_db(self, broker: Broker):
"""
Saves the task package to Django or the cache
:param task: the task package
:type broker: brokers.Broker
"""
# SAVE LIMIT < 0 : Don't save success
if not self.save and self.has_succeeded:
return
# enqueues next in a chain
if self.chain:
tasks.async_chain(
self.chain,
group=self.group,
cached=self.cached,
sync=self.sync,
broker=broker,
)
close_old_django_connections()
logger.debug(self.func_name)
try:
filters = {}
if (
Conf.SAVE_LIMIT_PER
and Conf.SAVE_LIMIT_PER in {"group", "name", "func"}
and Conf.SAVE_LIMIT_PER in self
):
value = getattr(self, Conf.SAVE_LIMIT_PER)
if Conf.SAVE_LIMIT_PER == "func":
value = self.func_name
filters[Conf.SAVE_LIMIT_PER] = value
with db.transaction.atomic(using=db.router.db_for_write(Success)):
last = Success.objects.filter(**filters).select_for_update().last()
if (
self.has_succeeded
and 0 < Conf.SAVE_LIMIT <= Success.objects.filter(**filters).count()
):
# delete the last entry if we are hitting the limit
last.delete()
# check if this task has previous results
existing_task, created = Task.objects.get_or_create(
id=self.id,
name=self.name,
defaults={
'func': self.func_name,
'stopped': self.finished_at,
'hook': self.hook,
'args': self.args,
'kwargs': self.kwargs,
'cluster': self.cluster,
'started': self.started_at,
'result': self.result,
'group': self.group,
'success': self.has_succeeded,
'attempt_count': 1
}
)
# only update the result if it hasn't succeeded yet
if not created and not existing_task.success:
existing_task.stopped = self.finished_at
existing_task.result = self.result
existing_task.success = self.has_succeeded
existing_task.attempt_count += 1
existing_task.save()
if (
Conf.MAX_ATTEMPTS > 0
and existing_task.attempt_count >= Conf.MAX_ATTEMPTS
):
broker.acknowledge(self.ack_id)
return existing_task
except Exception:
logger.exception("Could not save task result")
def save_cached(self, broker: Broker):
task_key = f'{broker.list_key}:{self.id}'
timeout = self.cached
if timeout is True:
timeout = None
try:
group = self.group
iter_count = self.iter_count
# if it's a group append to the group list
if group:
group_key = f"{broker.list_key}:{group}:keys"
group_list = broker.cache.get(group_key) or []
# if it's an iter group, check if we are ready
if iter_count and len(group_list) == iter_count - 1:
group_args = f"{broker.list_key}:{group}:args"
# collate the results into a Task result
results = [
SignedPackage.loads(broker.cache.get(k)).result
for k in group_list
]
results.append(self.result)
self.result = results
self.id = group
self.args = SignedPackage.loads(broker.cache.get(group_args))
self.iter_count = None
self.group = None
if self.iter_cached:
self.cached = self.iter_cached
self.save_cached(broker=broker)
else:
self.save_to_db(broker)
broker.cache.delete_many(group_list)
broker.cache.delete_many([group_key, group_args])
return
# save the group list
group_list.append(task_key)
broker.cache.set(group_key, group_list, timeout)
# async_task next in a chain
if self.chain:
tasks.async_chain(
self.chain,
group=group,
cached=self.cached,
sync=self.sync,
broker=broker,
)
# save the task
broker.cache.set(task_key, SignedPackage.dumps(self), timeout)
except Exception:
logger.exception("Could not save task result")

83
django_q/queues.py Normal file
View File

@@ -0,0 +1,83 @@
"""
The code is derived from
https://github.com/althonos/pronto/commit/3384010dfb4fc7c66a219f59276adef3288a886b
"""
import multiprocessing
import multiprocessing.queues
import sys
class SharedCounter:
"""A synchronized shared counter.
The locking done by multiprocessing.Value ensures that only a single
process or thread may read or write the in-memory ctypes object. However,
in order to do n += 1, Python performs a read followed by a write, so a
second process may read the old value before the new one is written by
the first process. The solution is to use a multiprocessing.Lock to
guarantee the atomicity of the modifications to Value.
This class comes almost entirely from Eli Bendersky's blog:
http://eli.thegreenplace.net/2012/01/04/shared-counter-with-pythons-multiprocessing/
"""
def __init__(self, n=0):
self.count = multiprocessing.Value("i", n)
def increment(self, n=1):
"""Increment the counter by n (default = 1)"""
with self.count.get_lock():
self.count.value += n
@property
def value(self):
"""Return the value of the counter"""
return self.count.value
class Queue(multiprocessing.queues.Queue):
"""A portable implementation of multiprocessing.Queue.
Because of multithreading / multiprocessing semantics, Queue.qsize() may
raise the NotImplementedError exception on Unix platforms like Mac OS X
where sem_getvalue() is not implemented. This subclass addresses this
problem by using a synchronized shared counter (initialized to zero) and
increasing / decreasing its value every time the put() and get() methods
are called, respectively. This not only prevents NotImplementedError from
being raised, but also allows us to implement a reliable version of both
qsize() and empty().
"""
def __init__(self, *args, **kwargs):
if sys.version_info < (3, 0):
super(Queue, self).__init__(*args, **kwargs)
else:
super(Queue, self).__init__(
*args, ctx=multiprocessing.get_context(), **kwargs
)
self.size = SharedCounter(0)
def __getstate__(self):
return super(Queue, self).__getstate__() + (self.size,)
def __setstate__(self, state):
super(Queue, self).__setstate__(state[:-1])
self.size = state[-1]
def put(self, *args, **kwargs):
super(Queue, self).put(*args, **kwargs)
self.size.increment(1)
def get(self, *args, **kwargs):
x = super(Queue, self).get(*args, **kwargs)
self.size.increment(-1)
return x
def qsize(self) -> int:
"""Reliable implementation of multiprocessing.Queue.qsize()"""
return self.size.value
def empty(self) -> bool:
"""Reliable implementation of multiprocessing.Queue.empty()"""
return not self.qsize() > 0

View File

@@ -1,39 +1,72 @@
from django_q.utils import localtime
import uuid
from django_q.models import Schedule
from django_q import tasks
import ast import ast
from django_q.humanhash import humanize from multiprocessing.process import current_process
from django import db
from time import sleep from django import core, db
from django_q.brokers import get_broker from django.apps.registry import apps
from django.utils import timezone from django.utils import timezone
from multiprocessing import Value, current_process
from django_q.utils import close_old_django_connections
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
try:
apps.check_apps_ready()
except core.exceptions.AppRegistryNotReady:
import django
django.setup()
from django_q.brokers import Broker, get_broker
from django_q.conf import Conf, logger from django_q.conf import Conf, logger
from django_q.process_manager import ProcessManager from django_q.humanhash import humanize
from django_q.models import Schedule
from django_q.tasks import async_task
from django_q.utils import close_old_django_connections, localtime
def scheduler(broker: Broker = None):
"""
Creates a task from a schedule at the scheduled time and schedules next run
"""
if not broker:
broker = get_broker()
close_old_django_connections()
try:
# Only default cluster will handler schedule with default(null) cluster
Q_default = (
db.models.Q(cluster__isnull=True)
if Conf.CLUSTER_NAME == Conf.PREFIX
else db.models.Q(pk__in=[])
)
class Scheduler(ProcessManager):
"""The Scheduler is responsible for scheduling new tasks"""
@staticmethod
def schedule_tasks(broker=None):
logger.debug("Start sheduling")
if broker is None:
broker = get_broker()
q_default = db.models.Q(cluster__isnull=True) if Conf.CLUSTER_NAME == Conf.PREFIX else db.models.Q(pk__in=[])
with db.transaction.atomic(using=db.router.db_for_write(Schedule)): with db.transaction.atomic(using=db.router.db_for_write(Schedule)):
for s in ( for s in (
Schedule.objects.select_for_update() Schedule.objects.select_for_update()
.exclude(repeats=0) .exclude(repeats=0)
.filter(db.models.Q(next_run__lt=timezone.now()), q_default | db.models.Q(cluster=Conf.CLUSTER_NAME)) .filter(next_run__lt=timezone.now())
.filter(Q_default | db.models.Q(cluster=Conf.CLUSTER_NAME))
): ):
args = s.parse_args() args = ()
kwargs = s.parse_kwargs() kwargs = {}
# get args, kwargs and hook
if s.kwargs:
try:
# first try the dict syntax
kwargs = ast.literal_eval(s.kwargs)
except (SyntaxError, ValueError):
# else use the kwargs syntax
try:
parsed_kwargs = (
ast.parse(f"f({s.kwargs})").body[0].value.keywords
)
kwargs = {
kwarg.arg: ast.literal_eval(kwarg.value)
for kwarg in parsed_kwargs
}
except (SyntaxError, ValueError):
kwargs = {}
if s.args:
args = ast.literal_eval(s.args)
# single value won't eval to tuple, so:
if type(args) != tuple:
args = (args,)
q_options = kwargs.get("q_options", {}) q_options = kwargs.get("q_options", {})
if s.intended_date_kwarg: if s.intended_date_kwarg:
kwargs[s.intended_date_kwarg] = s.next_run.isoformat() kwargs[s.intended_date_kwarg] = s.next_run.isoformat()
@@ -41,23 +74,35 @@ class Scheduler(ProcessManager):
q_options["hook"] = s.hook q_options["hook"] = s.hook
# set up the next run time # set up the next run time
if s.schedule_type != s.ONCE: if s.schedule_type != s.ONCE:
next_run = s.calculate_next_run(s.next_run) next_run = s.next_run
if not Conf.CATCH_UP: while True:
while next_run <= localtime(): next_run = s.calculate_next_run(next_run)
next_run = s.calculate_next_run(next_run) if Conf.CATCH_UP or next_run > localtime():
break
s.next_run = next_run s.next_run = next_run
s.repeats += -1
# send it to the cluster; any cluster name is allowed in multi-queue scenarios
# because `broker_name` is confusing, using `cluster` name is recommended and take
q_options["cluster"] = s.cluster or q_options.get("cluster", q_options.pop("broker_name", None))
if q_options['cluster'] is None or q_options['cluster'] == Conf.CLUSTER_NAME:
q_options["broker"] = broker
# Little Fix for already broken numbers
if s.repeats < -1:
s.repeats = -1
# Check if the value is not zero
if s.repeats > 0:
s.repeats -= 1
# send it to the cluster; any cluster name is allowed in multi-queue scenarios
# because `broker_name` is confusing, using `cluster` name is recommended and takes precedence
q_options["cluster"] = s.cluster or q_options.get(
"cluster", q_options.pop("broker_name", None)
)
if (
q_options["cluster"] is None
or q_options["cluster"] == Conf.CLUSTER_NAME
):
q_options["broker"] = broker
q_options["group"] = q_options.get("group", s.name or s.id) q_options["group"] = q_options.get("group", s.name or s.id)
kwargs["q_options"] = q_options kwargs["q_options"] = q_options
s.task = async_task(s.func, *args, **kwargs)
s.task = tasks.async_task(s.func, *args, **kwargs)
# log it # log it
if not s.task: if not s.task:
logger.error( logger.error(
@@ -91,35 +136,5 @@ class Scheduler(ProcessManager):
s.repeats = 0 s.repeats = 0
# save the schedule # save the schedule
s.save() s.save()
except Exception:
def get_target(self): logger.exception("Could not create task from schedule")
return self.run_scheduler
def stop_scheduler(self) -> None:
# send stop signal to worker
try:
self.manager_pipe.send("STOP")
except BrokenPipeError:
# recycle process if pipe is broken
self.status.value = ProcessManager.Status.DONE.value
def run_scheduler(self, status, pipe) -> None:
self.process_name = current_process().name
self.process_id = current_process().pid
status.value = self.Status.BUSY.value
logger.info(
_("%(proc_name)s scheduling at %(id)s")
% {"proc_name": self.process_name, "id": self.process_id}
)
while True:
if pipe.poll() and pipe.recv() == "STOP":
status.value = self.Status.DONE.value
break
broker = get_broker()
close_old_django_connections()
try:
Scheduler.schedule_tasks(broker=broker)
except Exception:
logger.exception("Could not create task from schedule")
# sleep 60 seconds for next schedule
sleep(60)

View File

@@ -32,6 +32,9 @@ def call_hook(sender, instance, **kwargs):
) )
# args: proc_name
post_spawn = Signal()
# args: task # args: task
pre_enqueue = Signal() pre_enqueue = Signal()

View File

@@ -1,4 +1,5 @@
"""Package signing.""" """Package signing."""
import pickle import pickle
from django_q import core_signing as signing from django_q import core_signing as signing

View File

@@ -41,7 +41,14 @@ class Stat(Status):
self.status = sentinel.status() self.status = sentinel.status()
self.done_q_size = 0 self.done_q_size = 0
self.task_q_size = 0 self.task_q_size = 0
self.workers = [w.process.pid for w in sentinel.pool.workers] if Conf.QSIZE:
self.done_q_size = sentinel.result_queue.qsize()
self.task_q_size = sentinel.task_queue.qsize()
if sentinel.monitor:
self.monitor = sentinel.monitor.pid
if sentinel.pusher:
self.pusher = sentinel.pusher.pid
self.workers = [w.pid for w in sentinel.pool]
def uptime(self) -> float: def uptime(self) -> float:
return (timezone.now() - self.tob).total_seconds() return (timezone.now() - self.tob).total_seconds()

View File

@@ -1,7 +1,6 @@
"""Provides task functionality.""" """Provides task functionality."""
# Standard # Standard
from django_q.helpers import run_cluster_once
from django_q.queue_task import QueueTask
from multiprocessing import Value from multiprocessing import Value
from time import sleep, time from time import sleep, time
@@ -14,13 +13,14 @@ from django_q.brokers import get_broker
from django_q.conf import Conf, logger from django_q.conf import Conf, logger
from django_q.humanhash import uuid from django_q.humanhash import uuid
from django_q.models import Schedule, Task from django_q.models import Schedule, Task
from django_q.queues import Queue
from django_q.signals import pre_enqueue from django_q.signals import pre_enqueue
from django_q.signing import SignedPackage from django_q.signing import SignedPackage
def async_task(func, *args, **kwargs): def async_task(func, *args, **kwargs):
"""Queue a task for the cluster.""" """Queue a task for the cluster."""
given_kwargs = kwargs.copy() keywords = kwargs.copy()
opt_keys = ( opt_keys = (
"hook", "hook",
"group", "group",
@@ -35,40 +35,47 @@ def async_task(func, *args, **kwargs):
"cluster", "cluster",
"timeout", "timeout",
) )
q_options = given_kwargs.pop("q_options", {}) q_options = keywords.pop("q_options", {})
# get an id # get an id
tag = uuid() tag = uuid()
# build the task package # build the task package
task = QueueTask( task = {
id=tag[1], "id": tag[1],
name=given_kwargs.pop("task_name", None) or q_options.pop("task_name", None) or tag[0], "name": keywords.pop("task_name", None)
func=func, or q_options.pop("task_name", None)
args=args or tag[0],
) "func": func,
"args": args,
# don't serialize the broker }
broker = given_kwargs.pop("broker", None) or q_options.pop("broker", None) or get_broker(task.cluster) or get_broker()
print(broker.list_key)
# push optionals # push optionals
for key in opt_keys: for key in opt_keys:
if key in q_options or key in given_kwargs: if q_options and key in q_options:
setattr(task, key, q_options.pop(key, None) or given_kwargs.pop(key, None)) task[key] = q_options[key]
elif key in keywords:
task[key] = keywords.pop(key)
# don't serialize the broker
broker = task.pop("broker", None) or get_broker(task.get("cluster"))
# overrides
if "cached" not in task and Conf.CACHED:
task["cached"] = Conf.CACHED
if "sync" not in task and Conf.SYNC:
task["sync"] = Conf.SYNC
if "ack_failure" not in task and Conf.ACK_FAILURES:
task["ack_failure"] = Conf.ACK_FAILURES
# finalize # finalize
task.kwargs = given_kwargs task["kwargs"] = keywords
task["started"] = timezone.now()
# signal it # signal it
pre_enqueue.send(sender="django_q", task=task) pre_enqueue.send(sender="django_q", task=task)
# sign it # sign it
pack = SignedPackage.dumps(task) pack = SignedPackage.dumps(task)
if task.sync: if task.get("sync", False):
return _sync(pack) return _sync(pack)
# push it # push it
enqueue_id = broker.enqueue(pack) enqueue_id = broker.enqueue(pack)
logger.info(f"Enqueued [{broker.list_key}] {enqueue_id}") logger.info(f"Enqueued [{broker.list_key}] {enqueue_id}")
logger.debug(f"Pushed {tag}") logger.debug(f"Pushed {tag}")
return task.id return task["id"]
def schedule(func, *args, **kwargs): def schedule(func, *args, **kwargs):
@@ -105,7 +112,7 @@ def schedule(func, *args, **kwargs):
raise IntegrityError("A schedule with the same name already exists.") raise IntegrityError("A schedule with the same name already exists.")
# create and return the schedule # create and return the schedule
schedule = Schedule( s = Schedule(
name=name, name=name,
func=func, func=func,
hook=hook, hook=hook,
@@ -119,9 +126,11 @@ def schedule(func, *args, **kwargs):
cluster=cluster, cluster=cluster,
intended_date_kwarg=intended_date_kwarg, intended_date_kwarg=intended_date_kwarg,
) )
schedule.full_clean() # make sure we trigger validation
schedule.save() s.full_clean()
return schedule s.save()
return s
def result(task_id, wait=0, cached=Conf.CACHED): def result(task_id, wait=0, cached=Conf.CACHED):
""" """
@@ -140,7 +149,7 @@ def result(task_id, wait=0, cached=Conf.CACHED):
start = time() start = time()
while True: while True:
r = Task.get_result(task_id) r = Task.get_result(task_id)
if r: if r is not None:
return r return r
if (time() - start) * 1000 >= wait >= 0: if (time() - start) * 1000 >= wait >= 0:
break break
@@ -157,7 +166,7 @@ def result_cached(task_id, wait=0, broker=None):
while True: while True:
r = broker.cache.get(f"{broker.list_key}:{task_id}") r = broker.cache.get(f"{broker.list_key}:{task_id}")
if r: if r:
return SignedPackage.loads(r).result return SignedPackage.loads(r)["result"]
if (time() - start) * 1000 >= wait >= 0: if (time() - start) * 1000 >= wait >= 0:
break break
sleep(0.01) sleep(0.01)
@@ -216,8 +225,8 @@ def result_group_cached(group_id, failures=False, wait=0, count=None, broker=Non
result_list = [] result_list = []
for task_key in group_list: for task_key in group_list:
task = SignedPackage.loads(broker.cache.get(task_key)) task = SignedPackage.loads(broker.cache.get(task_key))
if task.has_succeeded or failures: if task["success"] or failures:
result_list.append(task.result) result_list.append(task["result"])
return result_list return result_list
if (time() - start) * 1000 >= wait >= 0: if (time() - start) * 1000 >= wait >= 0:
break break
@@ -260,17 +269,17 @@ def fetch_cached(task_id, wait=0, broker=None):
if r: if r:
task = SignedPackage.loads(r) task = SignedPackage.loads(r)
return Task( return Task(
id=task.id, id=task["id"],
name=task.name, name=task["name"],
func=task.func, func=task["func"],
hook=task.hook, hook=task.get("hook"),
args=task.args, args=task["args"],
kwargs=task.kwargs, kwargs=task["kwargs"],
cluster=task.cluster, cluster=task.get("cluster"),
started=task.started_at, started=task["started"],
stopped=task.finished_at, stopped=task["stopped"],
result=task.result, result=task["result"],
success=task.has_succeeded, success=task["success"],
) )
if (time() - start) * 1000 >= wait >= 0: if (time() - start) * 1000 >= wait >= 0:
break break
@@ -329,20 +338,20 @@ def fetch_group_cached(group_id, failures=True, wait=0, count=None, broker=None)
task_list = [] task_list = []
for task_key in group_list: for task_key in group_list:
task = SignedPackage.loads(broker.cache.get(task_key)) task = SignedPackage.loads(broker.cache.get(task_key))
if task.has_succeeded or failures: if task["success"] or failures:
t = Task( t = Task(
id=task.id, id=task["id"],
name=task.name, name=task["name"],
func=task.func, func=task["func"],
hook=task.hook, hook=task.get("hook"),
args=task.args, args=task["args"],
kwargs=task.kwargs, kwargs=task["kwargs"],
cluster=task.cluster, cluster=task.get("cluster"),
started=task.started_at, started=task["started"],
stopped=task.finished_at, stopped=task["stopped"],
result=task.result, result=task["result"],
group=task.group, group=task.get("group"),
success=task.has_succeeded, success=task["success"],
) )
task_list.append(t) task_list.append(t)
return task_list return task_list
@@ -379,7 +388,7 @@ def count_group_cached(group_id, failures=False, broker=None):
failure_count = 0 failure_count = 0
for task_key in group_list: for task_key in group_list:
task = SignedPackage.loads(broker.cache.get(task_key)) task = SignedPackage.loads(broker.cache.get(task_key))
if not task.has_succeeded: if not task["success"]:
failure_count += 1 failure_count += 1
return failure_count return failure_count
@@ -561,10 +570,12 @@ class Chain:
A sequential chain of tasks A sequential chain of tasks
""" """
def __init__(self, chain=None, group=None, cached=Conf.CACHED, sync=Conf.SYNC): def __init__(
self, chain=None, group=None, cached=Conf.CACHED, sync=Conf.SYNC, broker=None
):
self.chain = chain or [] self.chain = chain or []
self.group = group or "" self.group = group or ""
self.broker = get_broker() self.broker = broker or get_broker()
self.cached = cached self.cached = cached
self.sync = sync self.sync = sync
self.started = False self.started = False
@@ -721,17 +732,14 @@ class AsyncTask:
return self.id return self.id
def result(self, wait=0): def result(self, wait=0):
if self.started: if self.started:
return result(self.id, wait=wait, cached=self.cached) return result(self.id, wait=wait, cached=self.cached)
def fetch(self, wait=0): def fetch(self, wait=0):
if self.started: if self.started:
return fetch(self.id, wait=wait, cached=self.cached) return fetch(self.id, wait=wait, cached=self.cached)
def result_group(self, failures=False, wait=0, count=None): def result_group(self, failures=False, wait=0, count=None):
if self.started and self.group: if self.started and self.group:
return result_group( return result_group(
self.group, self.group,
@@ -742,7 +750,6 @@ class AsyncTask:
) )
def fetch_group(self, failures=True, wait=0, count=None): def fetch_group(self, failures=True, wait=0, count=None):
if self.started and self.group: if self.started and self.group:
return fetch_group( return fetch_group(
self.group, self.group,
@@ -755,7 +762,19 @@ class AsyncTask:
def _sync(pack): def _sync(pack):
"""Simulate a package travelling through the cluster.""" """Simulate a package travelling through the cluster."""
task = SignedPackage.loads(pack) from django_q.monitor import monitor
run_cluster_once(workers=1, tasks=[task]) from django_q.worker import worker
return task.id task_queue = Queue()
result_queue = Queue()
task = SignedPackage.loads(pack)
task_queue.put(task)
task_queue.put("STOP")
worker(task_queue, result_queue, Value("f", -1))
result_queue.put("STOP")
monitor(result_queue)
task_queue.close()
task_queue.join_thread()
result_queue.close()
result_queue.join_thread()
return task["id"]

View File

@@ -46,6 +46,10 @@ def hello():
return "hello" return "hello"
def return_falsy_value():
return []
def result(obj): def result(obj):
print(f"RESULT HOOK {obj.name} : {obj.result()}") print(f"RESULT HOOK {obj.name} : {obj.result()}")

View File

@@ -64,7 +64,7 @@ def test_admin_views(admin_client, monkeypatch):
# resubmit the failure # resubmit the failure
url = reverse("admin:django_q_failure_changelist") url = reverse("admin:django_q_failure_changelist")
data = {"action": "retry_failed", "_selected_action": [f.pk]} data = {"action": "resubmit_task", "_selected_action": [f.pk]}
response = admin_client.post(url, data) response = admin_client.post(url, data)
assert response.status_code == 302 assert response.status_code == 302
assert Failure.objects.filter(name=f.id).exists() is False assert Failure.objects.filter(name=f.id).exists() is False
@@ -84,3 +84,10 @@ def test_admin_views(admin_client, monkeypatch):
data = {"post": "yes"} data = {"post": "yes"}
response = admin_client.post(url, data) response = admin_client.post(url, data)
assert response.status_code == 302 assert response.status_code == 302
# Resubmit a successful task.
url = reverse("admin:django_q_success_changelist")
data = {"action": "resubmit_task", "_selected_action": [t.pk]}
initial_queue_count = OrmQ.objects.count()
response = admin_client.post(url, data)
assert response.status_code == 302
assert OrmQ.objects.count() > initial_queue_count

View File

@@ -1,11 +1,12 @@
from django_q.helpers import get_scheduled_tasks, run_task, save_task
from multiprocessing import Event, Value from multiprocessing import Event, Value
import pytest import pytest
from django_q.brokers import get_broker from django_q.brokers import get_broker
from django_q.conf import Conf from django_q.conf import Conf
from queue import Queue from django_q.monitor import monitor
from django_q.pusher import pusher
from django_q.queues import Queue
from django_q.tasks import ( from django_q.tasks import (
AsyncTask, AsyncTask,
Chain, Chain,
@@ -21,6 +22,7 @@ from django_q.tasks import (
result, result,
result_group, result_group,
) )
from django_q.worker import worker
@pytest.fixture @pytest.fixture
@@ -54,14 +56,20 @@ def test_cached(broker):
# run a single inline cluster # run a single inline cluster
task_count = 17 task_count = 17
assert broker.queue_size() == task_count assert broker.queue_size() == task_count
tasks = [] task_queue = Queue()
for task in range(17): stop_event = Event()
tasks += get_scheduled_tasks(broker=broker) stop_event.set()
for i in range(task_count):
pusher(task_queue, stop_event, broker=broker)
assert broker.queue_size() == 0 assert broker.queue_size() == 0
assert len(tasks) == task_count assert task_queue.qsize() == task_count
for task in tasks: task_queue.put("STOP")
run_task(task=task) result_queue = Queue()
save_task(task=task, broker=broker) worker(task_queue, result_queue, Value("f", -1))
assert result_queue.qsize() == task_count
result_queue.put("STOP")
monitor(result_queue)
assert result_queue.qsize() == 0
# assert results # assert results
assert result(task_id, wait=500, cached=True) == -1 assert result(task_id, wait=500, cached=True) == -1
assert fetch(task_id, wait=500, cached=True).result == -1 assert fetch(task_id, wait=500, cached=True).result == -1
@@ -155,7 +163,6 @@ def test_chain(broker):
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.skip("broken")
def test_asynctask_class(broker, monkeypatch): def test_asynctask_class(broker, monkeypatch):
broker.purge_queue() broker.purge_queue()
broker.cache.clear() broker.cache.clear()

View File

@@ -1,8 +1,4 @@
from django_q.worker import Worker
from django_q.queue_task import QueueTask
from django_q.helpers import get_scheduled_tasks, run_cluster_once, run_task, save_task
import os import os
import copy
import sys import sys
import threading import threading
import uuid as uuidlib import uuid as uuidlib
@@ -20,7 +16,9 @@ from django_q.cluster import Cluster, Sentinel
from django_q.conf import Conf from django_q.conf import Conf
from django_q.humanhash import DEFAULT_WORDLIST, uuid from django_q.humanhash import DEFAULT_WORDLIST, uuid
from django_q.models import Success, Task from django_q.models import Success, Task
from queue import Queue from django_q.monitor import monitor, save_task
from django_q.pusher import pusher
from django_q.queues import Queue
from django_q.signals import post_execute, pre_enqueue, pre_execute from django_q.signals import post_execute, pre_enqueue, pre_execute
from django_q.status import Stat from django_q.status import Stat
from django_q.tasks import ( from django_q.tasks import (
@@ -33,8 +31,9 @@ from django_q.tasks import (
result, result,
result_group, result_group,
) )
from django_q.tests.tasks import multiply, TaskError from django_q.tests.tasks import TaskError, multiply
from django_q.utils import add_months, add_years from django_q.utils import add_months, add_years
from django_q.worker import worker
myPath = os.path.dirname(os.path.abspath(__file__)) myPath = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, myPath + "/../") sys.path.insert(0, myPath + "/../")
@@ -72,21 +71,42 @@ def test_sync_raise_exception(broker):
async_task("django_q.tests.tasks.raise_exception", broker=broker, sync=True) async_task("django_q.tests.tasks.raise_exception", broker=broker, sync=True)
# @pytest.mark.django_db @pytest.mark.django_db
# skipped due to broken pipe def test_cluster_initial(broker):
# def test_sentinel(): broker.list_key = "initial_test:q"
# start_event = Event() broker.delete_queue()
# stop_event = Event() c = Cluster(broker=broker)
# stop_event.set() assert c.sentinel is None
# cluster_id = uuidlib.uuid4() assert c.stat.status == Conf.STOPPED
# s = Sentinel( assert c.start() > 0
# stop_event, assert c.sentinel.is_alive() is True
# start_event, assert c.is_running
# cluster_id=cluster_id, assert c.is_stopping is False
# broker=get_broker("sentinel_test:q"), assert c.is_starting is False
# ) sleep(0.5)
# assert start_event.is_set() stat = c.stat
# assert s.status() == Conf.STOPPING assert stat.status == Conf.IDLE
assert c.stop() is True
assert c.sentinel.is_alive() is False
assert c.has_stopped
assert c.stop() is False
broker.delete_queue()
@pytest.mark.django_db
def test_sentinel():
start_event = Event()
stop_event = Event()
stop_event.set()
cluster_id = uuidlib.uuid4()
s = Sentinel(
stop_event,
start_event,
cluster_id=cluster_id,
broker=get_broker("sentinel_test:q"),
)
assert start_event.is_set()
assert s.status() == Conf.STOPPED
@pytest.mark.django_db @pytest.mark.django_db
@@ -97,19 +117,54 @@ def test_cluster(broker):
"django_q.tests.tasks.count_letters", DEFAULT_WORDLIST, broker=broker "django_q.tests.tasks.count_letters", DEFAULT_WORDLIST, broker=broker
) )
assert broker.queue_size() == 1 assert broker.queue_size() == 1
task_queue = Queue()
assert task_queue.qsize() == 0
result_queue = Queue()
assert result_queue.qsize() == 0
event = Event()
event.set()
# Test push # Test push
tasks = get_scheduled_tasks(broker=broker) pusher(task_queue, event, broker=broker)
assert len(tasks) == 1 assert task_queue.qsize() == 1
assert queue_size(broker=broker) == 0 assert queue_size(broker=broker) == 0
# Test work # Test work
task = run_task(tasks[0]) task_queue.put("STOP")
worker(task_queue, result_queue, Value("f", -1))
assert task_queue.qsize() == 0
assert result_queue.qsize() == 1
# Test monitor # Test monitor
save_task(task=task) result_queue.put("STOP")
monitor(result_queue)
assert result_queue.qsize() == 0
# check result # check result
assert result(task.id) == 1506 assert result(task) == 1506
broker.delete_queue() broker.delete_queue()
@pytest.mark.django_db
def test_results(broker):
broker.list_key = "cluster_test:q"
broker.delete_queue()
a = async_task(
"django_q.tests.tasks.return_falsy_value",
broker=broker,
)
task_queue = Queue()
stop_event = Event()
stop_event.set()
pusher(task_queue, stop_event, broker=broker)
task_queue.put("STOP")
result_queue = Queue()
worker(task_queue, result_queue, Value("f", -1))
result_queue.put("STOP")
monitor(result_queue)
# should not loop indefinitely when a real value is returned
value = result(a, wait=-1)
assert value == []
@pytest.mark.django_db @pytest.mark.django_db
def test_enqueue(broker, admin_user): def test_enqueue(broker, admin_user):
broker.list_key = "cluster_test:q" broker.list_key = "cluster_test:q"
@@ -183,14 +238,15 @@ def test_enqueue(broker, admin_user):
# run the cluster to execute the tasks # run the cluster to execute the tasks
task_count = 10 task_count = 10
assert broker.queue_size() == task_count assert broker.queue_size() == task_count
task_queue = Queue()
stop_event = Event() stop_event = Event()
stop_event.set() stop_event.set()
# push the tasks # push the tasks
tasks = []
for _ in range(task_count): for _ in range(task_count):
tasks += get_scheduled_tasks(broker=broker) pusher(task_queue, stop_event, broker=broker)
assert broker.queue_size() == 0 assert broker.queue_size() == 0
assert len(tasks) == task_count assert task_queue.qsize() == task_count
task_queue.put("STOP")
# test wait timeout # test wait timeout
assert result(j, wait=10) is None assert result(j, wait=10) is None
assert fetch(j, wait=10) is None assert fetch(j, wait=10) is None
@@ -199,10 +255,13 @@ def test_enqueue(broker, admin_user):
assert fetch_group("test_j", wait=10) is None assert fetch_group("test_j", wait=10) is None
assert fetch_group("test_j", count=2, wait=10) is None assert fetch_group("test_j", count=2, wait=10) is None
# let a worker handle them # let a worker handle them
for task in tasks: result_queue = Queue()
run_task(task=task) worker(task_queue, result_queue, Value("f", -1))
save_task(task=task) assert result_queue.qsize() == task_count
result_queue.put("STOP")
# store the results
monitor(result_queue)
assert result_queue.qsize() == 0
# Check the results # Check the results
# task a # task a
result_a = fetch(a) result_a = fetch(a)
@@ -228,11 +287,10 @@ def test_enqueue(broker, admin_user):
assert result_e.success is True assert result_e.success is True
assert result(e) is None assert result(e) is None
# task f # task f
# @TODO: fix this result_f = fetch(f)
# result_f = fetch(f) assert result_f is not None
# assert result_f is not None assert result_f.success is True
# assert result_f.success is True assert result(f) == 1506
# assert result(f) == 1506
# task g # task g
result_g = fetch(g) result_g = fetch(g)
assert result_g is not None assert result_g is not None
@@ -275,153 +333,152 @@ def test_enqueue(broker, admin_user):
broker.delete_queue() broker.delete_queue()
# @pytest.mark.django_db @pytest.mark.django_db
# @pytest.mark.parametrize( @pytest.mark.parametrize(
# "cluster_config_timeout, async_task_kwargs", "cluster_config_timeout, async_task_kwargs",
# ( (
# (1, {}), (1, {}),
# (10, {"timeout": 1}), (10, {"timeout": 1}),
# (None, {"timeout": 1}), (None, {"timeout": 1}),
# ), ),
# ) )
# def test_timeout(broker, cluster_config_timeout, async_task_kwargs): def test_timeout(broker, cluster_config_timeout, async_task_kwargs):
# # set up the Sentinel # set up the Sentinel
# broker.list_key = "timeout_test:q" broker.list_key = "timeout_test:q"
# broker.purge_queue() broker.purge_queue()
# async_task("time.sleep", 5, broker=broker, **async_task_kwargs) async_task("time.sleep", 5, broker=broker, **async_task_kwargs)
# start_event = Event() start_event = Event()
# stop_event = Event() stop_event = Event()
# cluster_id = uuidlib.uuid4() cluster_id = uuidlib.uuid4()
# # Set a timer to stop the Sentinel # Set a timer to stop the Sentinel
# threading.Timer(3, stop_event.set).start() threading.Timer(3, stop_event.set).start()
# s = Sentinel( s = Sentinel(
# stop_event, stop_event,
# start_event, start_event,
# cluster_id=cluster_id, cluster_id=cluster_id,
# broker=broker, broker=broker,
# timeout=cluster_config_timeout, timeout=cluster_config_timeout,
# ) )
# assert start_event.is_set() assert start_event.is_set()
# assert s.status() == Conf.STOPPED assert s.status() == Conf.STOPPED
# assert s.reincarnations == 1 assert s.reincarnations == 1
# broker.delete_queue() broker.delete_queue()
# @pytest.mark.django_db @pytest.mark.django_db
# @pytest.mark.parametrize( @pytest.mark.parametrize(
# "cluster_config_timeout, async_task_kwargs", "cluster_config_timeout, async_task_kwargs",
# ( (
# (5, {}), (5, {}),
# (10, {"timeout": 5}), (10, {"timeout": 5}),
# (1, {"timeout": 5}), (1, {"timeout": 5}),
# (None, {"timeout": 5}), (None, {"timeout": 5}),
# ), ),
# ) )
# def test_timeout_task_finishes(broker, cluster_config_timeout, async_task_kwargs): def test_timeout_task_finishes(broker, cluster_config_timeout, async_task_kwargs):
# # set up the Sentinel # set up the Sentinel
# broker.list_key = "timeout_test:q" broker.list_key = "timeout_test:q"
# broker.purge_queue() broker.purge_queue()
# async_task("time.sleep", 3, broker=broker, **async_task_kwargs) async_task("time.sleep", 3, broker=broker, **async_task_kwargs)
# start_event = Event() start_event = Event()
# stop_event = Event() stop_event = Event()
# cluster_id = uuidlib.uuid4() cluster_id = uuidlib.uuid4()
# # Set a timer to stop the Sentinel # Set a timer to stop the Sentinel
# threading.Timer(6, stop_event.set).start() threading.Timer(6, stop_event.set).start()
# s = Sentinel( s = Sentinel(
# stop_event, stop_event,
# start_event, start_event,
# cluster_id=cluster_id, cluster_id=cluster_id,
# broker=broker, broker=broker,
# timeout=cluster_config_timeout, timeout=cluster_config_timeout,
# ) )
# assert start_event.is_set() assert start_event.is_set()
# assert s.status() == Conf.STOPPED assert s.status() == Conf.STOPPED
# assert s.reincarnations == 0 assert s.reincarnations == 0
# broker.delete_queue() broker.delete_queue()
# @pytest.mark.django_db @pytest.mark.django_db
# def test_recycle(broker, monkeypatch): def test_recycle(broker, monkeypatch):
# # set up the Sentinel # set up the Sentinel
# broker.list_key = "test_recycle_test:q" broker.list_key = "test_recycle_test:q"
# async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
# async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
# async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
# start_event = Event() start_event = Event()
# stop_event = Event() stop_event = Event()
# cluster_id = uuidlib.uuid4() cluster_id = uuidlib.uuid4()
# # override settings # override settings
# monkeypatch.setattr(Conf, "RECYCLE", 2) monkeypatch.setattr(Conf, "RECYCLE", 2)
# monkeypatch.setattr(Conf, "WORKERS", 1) monkeypatch.setattr(Conf, "WORKERS", 1)
# # set a timer to stop the Sentinel # set a timer to stop the Sentinel
# threading.Timer(3, stop_event.set).start() threading.Timer(3, stop_event.set).start()
# s = Sentinel(stop_event, start_event, cluster_id=cluster_id, broker=broker) s = Sentinel(stop_event, start_event, cluster_id=cluster_id, broker=broker)
# assert start_event.is_set() assert start_event.is_set()
# assert s.status() == Conf.STOPPED assert s.status() == Conf.STOPPED
# assert s.reincarnations == 1 assert s.reincarnations == 1
# async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
# async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
# task_queue = Queue() task_queue = Queue()
# result_queue = Queue() result_queue = Queue()
# # push two tasks # push two tasks
# # pusher(task_queue, stop_event, broker=broker) pusher(task_queue, stop_event, broker=broker)
# # pusher(task_queue, stop_event, broker=broker) pusher(task_queue, stop_event, broker=broker)
# # worker should exit on recycle # worker should exit on recycle
# # worker(task_queue, result_queue, Value("f", -1)) worker(task_queue, result_queue, Value("f", -1))
# # check if the work has been done # check if the work has been done
# assert result_queue.qsize() == 2 assert result_queue.qsize() == 2
# # save_limit test # save_limit test
# monkeypatch.setattr(Conf, "SAVE_LIMIT", 1) monkeypatch.setattr(Conf, "SAVE_LIMIT", 1)
# result_queue.put("STOP") result_queue.put("STOP")
# # run monitor # run monitor
# # monitor(result_queue) monitor(result_queue)
# assert Success.objects.count() == Conf.SAVE_LIMIT assert Success.objects.count() == Conf.SAVE_LIMIT
# broker.delete_queue() broker.delete_queue()
# @pytest.mark.django_db @pytest.mark.django_db
# def test_save_limit_per_func(broker, monkeypatch): def test_save_limit_per_func(broker, monkeypatch):
# # set up the Sentinel # set up the Sentinel
# broker.list_key = "test_recycle_test:q" broker.list_key = "test_recycle_test:q"
# async_task("django_q.tests.tasks.hello", broker=broker) async_task("django_q.tests.tasks.hello", broker=broker)
# async_task("django_q.tests.tasks.countdown", 2, broker=broker) async_task("django_q.tests.tasks.countdown", 2, broker=broker)
# async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
# start_event = Event() start_event = Event()
# stop_event = Event() stop_event = Event()
# cluster_id = uuidlib.uuid4() cluster_id = uuidlib.uuid4()
# task_queue = Queue() task_queue = Queue()
# result_queue = Queue() result_queue = Queue()
# # override settings # override settings
# monkeypatch.setattr(Conf, "RECYCLE", 3) monkeypatch.setattr(Conf, "RECYCLE", 3)
# monkeypatch.setattr(Conf, "WORKERS", 1) monkeypatch.setattr(Conf, "WORKERS", 1)
# # set a timer to stop the Sentinel # set a timer to stop the Sentinel
# threading.Timer(3, stop_event.set).start() threading.Timer(3, stop_event.set).start()
# # for i in range(3): for i in range(3):
# # pusher(task_queue, stop_event, broker=broker) pusher(task_queue, stop_event, broker=broker)
# # worker(task_queue, result_queue, Value("f", -1)) worker(task_queue, result_queue, Value("f", -1))
# s = Sentinel(stop_event, start_event, cluster_id=cluster_id, broker=broker) s = Sentinel(stop_event, start_event, cluster_id=cluster_id, broker=broker)
# assert start_event.is_set() assert start_event.is_set()
# assert s.status() == Conf.STOPPED assert s.status() == Conf.STOPPED
# # worker should exit on recycle # worker should exit on recycle
# # check if the work has been done # check if the work has been done
# assert result_queue.qsize() == 3 assert result_queue.qsize() == 3
# # save_limit test # save_limit test
# monkeypatch.setattr(Conf, "SAVE_LIMIT", 1) monkeypatch.setattr(Conf, "SAVE_LIMIT", 1)
# monkeypatch.setattr(Conf, "SAVE_LIMIT_PER", "func") monkeypatch.setattr(Conf, "SAVE_LIMIT_PER", "func")
# result_queue.put("STOP") result_queue.put("STOP")
# # run monitor # run monitor
# # monitor(result_queue) monitor(result_queue)
# assert Success.objects.count() == 3 assert Success.objects.count() == 3
# assert set(Success.objects.filter().values_list("func", flat=True)) == { assert set(Success.objects.filter().values_list("func", flat=True)) == {
# "django_q.tests.tasks.countdown", "django_q.tests.tasks.countdown",
# "django_q.tests.tasks.hello", "django_q.tests.tasks.hello",
# "django_q.tests.tasks.multiply", "django_q.tests.tasks.multiply",
# } }
# broker.delete_queue() broker.delete_queue()
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.skip("broken")
def test_max_rss(broker, monkeypatch): def test_max_rss(broker, monkeypatch):
# set up the Sentinel # set up the Sentinel
broker.list_key = "test_max_rss_test:q" broker.list_key = "test_max_rss_test:q"
@@ -436,18 +493,27 @@ def test_max_rss(broker, monkeypatch):
threading.Timer(3, stop_event.set).start() threading.Timer(3, stop_event.set).start()
s = Sentinel(stop_event, start_event, cluster_id=cluster_id, broker=broker) s = Sentinel(stop_event, start_event, cluster_id=cluster_id, broker=broker)
assert start_event.is_set() assert start_event.is_set()
assert s.status() == Conf.STOPPING assert s.status() == Conf.STOPPED
assert s.reincarnations == 1 assert s.reincarnations == 1
async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker) async_task("django_q.tests.tasks.multiply", 2, 2, broker=broker)
for _ in range(2): task_queue = Queue()
get_scheduled_tasks(broker=broker) result_queue = Queue()
worker = s.pool.workers[0] # push the task
s.pool.delegate_tasks() pusher(task_queue, stop_event, broker=broker)
assert worker.status == Worker.Status.Idle # worker should exit on recycle
worker(task_queue, result_queue, Value("f", -1))
# check if the work has been done
assert result_queue.qsize() == 1
# save_limit test
monkeypatch.setattr(Conf, "SAVE_LIMIT", 1)
result_queue.put("STOP")
# run monitor
monitor(result_queue)
assert Success.objects.count() == Conf.SAVE_LIMIT
broker.delete_queue()
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.skip("broken")
def test_bad_secret(broker, monkeypatch): def test_bad_secret(broker, monkeypatch):
broker.list_key = "test_bad_secret:q" broker.list_key = "test_bad_secret:q"
async_task("math.copysign", 1, -1, broker=broker) async_task("math.copysign", 1, -1, broker=broker)
@@ -464,8 +530,16 @@ def test_bad_secret(broker, monkeypatch):
stat = Stat.get_all() stat = Stat.get_all()
assert len(stat) == 0 assert len(stat) == 0
assert Stat.get(pid=s.parent_pid, cluster_id=cluster_id) is None assert Stat.get(pid=s.parent_pid, cluster_id=cluster_id) is None
task = get_scheduled_tasks(broker=broker) task_queue = Queue()
assert task == [] pusher(task_queue, stop_event, broker=broker)
result_queue = Queue()
task_queue.put("STOP")
worker(
task_queue,
result_queue,
Value("f", -1),
)
assert result_queue.qsize() == 0
broker.delete_queue() broker.delete_queue()
@@ -473,32 +547,32 @@ def test_bad_secret(broker, monkeypatch):
def test_attempt_count(broker, monkeypatch): def test_attempt_count(broker, monkeypatch):
monkeypatch.setattr(Conf, "MAX_ATTEMPTS", 3) monkeypatch.setattr(Conf, "MAX_ATTEMPTS", 3)
tag = uuid() tag = uuid()
task = QueueTask( task = {
id=tag[1], "id": tag[1],
name=tag[0], "name": tag[0],
func="math.copysign", "func": "math.copysign",
args=(1, -1), "args": (1, -1),
kwargs={}, "kwargs": {},
started_at=timezone.now(), "started": timezone.now(),
finished_at=timezone.now(), "stopped": timezone.now(),
status=QueueTask.Status.FAILED, "success": False,
result=None, "result": None,
) }
# initial save - no success # initial save - no success
save_task(task, broker) save_task(task, broker)
assert Task.objects.filter(id=task.id).exists() assert Task.objects.filter(id=task["id"]).exists()
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.attempt_count == 1 assert saved_task.attempt_count == 1
sleep(0.5) sleep(0.5)
# second save # second save
task.finished_at = timezone.now() task["stopped"] = timezone.now()
save_task(task, broker) save_task(task, broker)
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.attempt_count == 2 assert saved_task.attempt_count == 2
# third save - # third save -
task.finished_at = timezone.now() task["stopped"] = timezone.now()
save_task(task, broker) save_task(task, broker)
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.attempt_count == 3 assert saved_task.attempt_count == 3
# task should be removed from queue # task should be removed from queue
assert broker.queue_size() == 0 assert broker.queue_size() == 0
@@ -507,43 +581,43 @@ def test_attempt_count(broker, monkeypatch):
@pytest.mark.django_db @pytest.mark.django_db
def test_update_failed(broker): def test_update_failed(broker):
tag = uuid() tag = uuid()
task = QueueTask( task = {
id=tag[1], "id": tag[1],
name=tag[0], "name": tag[0],
func="math.copysign", "func": "math.copysign",
args=(1, -1), "args": (1, -1),
kwargs={}, "kwargs": {},
started_at=timezone.now(), "started": timezone.now(),
finished_at=timezone.now(), "stopped": timezone.now(),
status=QueueTask.Status.FAILED, "success": False,
result=None, "result": None,
) }
# initial save - no success # initial save - no success
save_task(task, broker) save_task(task, broker)
assert Task.objects.filter(id=task.id).exists() assert Task.objects.filter(id=task["id"]).exists()
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.success is False assert saved_task.success is False
sleep(0.5) sleep(0.5)
# second save - no success # second save - no success
old_stopped = task.finished_at old_stopped = task["stopped"]
task.finished_at = timezone.now() task["stopped"] = timezone.now()
save_task(task, broker) save_task(task, broker)
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.stopped > old_stopped assert saved_task.stopped > old_stopped
# third save - success # third save - success
task.finished_at = timezone.now() task["stopped"] = timezone.now()
task.result = "result" task["result"] = "result"
task.status = QueueTask.Status.SUCCESS task["success"] = True
save_task(task, broker) save_task(task, broker)
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.success is True assert saved_task.success is True
# fourth save - no success # fourth save - no success
task.result = None task["result"] = None
task.status = QueueTask.Status.FAILED task["success"] = False
task.finished_at = old_stopped task["stopped"] = old_stopped
save_task(task, broker) save_task(task, broker)
# should not overwrite success # should not overwrite success
saved_task = Task.objects.get(id=task.id) saved_task = Task.objects.get(id=task["id"])
assert saved_task.success is True assert saved_task.success is True
assert saved_task.result == "result" assert saved_task.result == "result"
@@ -560,40 +634,47 @@ def test_acknowledge_failure_override():
self.acknowledgements[task_id] = count + 1 self.acknowledgements[task_id] = count + 1
tag = uuid() tag = uuid()
task_fail_ack = QueueTask( task_fail_ack = {
id=tag[1], "id": tag[1],
name=tag[0], "name": tag[0],
ack_id="test_fail_ack_id", "ack_id": "test_fail_ack_id",
ack_failure=True, "ack_failure": True,
func="math.copysign", "func": "math.copysign",
args=(1, -1), "args": (1, -1),
kwargs={}, "kwargs": {},
started_at=timezone.now(), "started": timezone.now(),
finished_at=timezone.now(), "stopped": timezone.now(),
status=QueueTask.Status.SUCCESS, "success": False,
result=None, "result": None,
}
tag = uuid()
task_fail_no_ack = task_fail_ack.copy()
task_fail_no_ack.update(
{"id": tag[1], "name": tag[0], "ack_id": "test_fail_no_ack_id"}
) )
del task_fail_no_ack["ack_failure"]
tag = uuid() tag = uuid()
task_fail_no_ack = copy.deepcopy(task_fail_ack) task_success_ack = task_fail_ack.copy()
task_fail_no_ack.id = tag[1] task_success_ack.update(
task_fail_no_ack.name = tag[0] {
task_fail_no_ack.ack_id = None "id": tag[1],
task_fail_no_ack.ack_failure = False "name": tag[0],
"ack_id": "test_success_ack_id",
tag = uuid() "success": True,
task_success_ack = copy.deepcopy(task_fail_ack) }
task_success_ack.id = tag[1] )
task_success_ack.name = tag[0] del task_success_ack["ack_failure"]
task_success_ack.ack_id = "test_success_ack_id"
task_success_ack.status = QueueTask.Status.SUCCESS
task_success_ack.ack_failure = False
result_queue = Queue()
result_queue.put(task_fail_ack)
result_queue.put(task_fail_no_ack)
result_queue.put(task_success_ack)
result_queue.put("STOP")
broker = VerifyAckMockBroker(list_key="key") broker = VerifyAckMockBroker(list_key="key")
save_task(task_fail_ack, broker=broker) monitor(result_queue, broker)
save_task(task_fail_no_ack, broker=broker)
save_task(task_success_ack, broker=broker)
assert broker.acknowledgements.get("test_fail_ack_id") == 1 assert broker.acknowledgements.get("test_fail_ack_id") == 1
assert broker.acknowledgements.get("test_fail_no_ack_id") is None assert broker.acknowledgements.get("test_fail_no_ack_id") is None
@@ -606,7 +687,7 @@ class TestSignals:
broker.list_key = "pre_enqueue_test:q" broker.list_key = "pre_enqueue_test:q"
broker.delete_queue() broker.delete_queue()
self.signal_was_called: bool = False self.signal_was_called: bool = False
self.task = None self.task: Optional[dict] = None
def handler(sender, task, **kwargs): def handler(sender, task, **kwargs):
self.signal_was_called = True self.signal_was_called = True
@@ -615,7 +696,7 @@ class TestSignals:
pre_enqueue.connect(handler) pre_enqueue.connect(handler)
task_id = async_task("math.copysign", 1, -1, broker=broker) task_id = async_task("math.copysign", 1, -1, broker=broker)
assert self.signal_was_called is True assert self.signal_was_called is True
assert self.task.id == task_id assert self.task.get("id") == task_id
pre_enqueue.disconnect(handler) pre_enqueue.disconnect(handler)
broker.delete_queue() broker.delete_queue()
@@ -624,7 +705,7 @@ class TestSignals:
broker.list_key = "pre_execute_test:q" broker.list_key = "pre_execute_test:q"
broker.delete_queue() broker.delete_queue()
self.signal_was_called: bool = False self.signal_was_called: bool = False
self.task = None self.task: Optional[dict] = None
self.func = None self.func = None
def handler(sender, task, func, **kwargs): def handler(sender, task, func, **kwargs):
@@ -634,19 +715,27 @@ class TestSignals:
pre_execute.connect(handler) pre_execute.connect(handler)
task_id = async_task("math.copysign", 1, -1, broker=broker) task_id = async_task("math.copysign", 1, -1, broker=broker)
run_cluster_once(workers=1, broker=broker) task_queue = Queue()
result_queue = Queue()
event = Event()
event.set()
pusher(task_queue, event, broker=broker)
task_queue.put("STOP")
worker(task_queue, result_queue, Value("f", -1))
result_queue.put("STOP")
monitor(result_queue, broker)
broker.delete_queue() broker.delete_queue()
assert self.task.id == task_id
assert self.signal_was_called is True assert self.signal_was_called is True
assert self.func == 'math.copysign' assert self.task.get("id") == task_id
assert self.func == copysign
pre_execute.disconnect(handler) pre_execute.disconnect(handler)
@pytest.mark.django_db @pytest.mark.django_db
def test_post_execute_signal(self, broker): def test_post_execute_signal(self, broker):
broker.list_key = "post_execute_test:q" broker.list_key = "post_execute_test:q"
broker.delete_queue() broker.delete_queue()
self.signal_was_called = False self.signal_was_called: bool = False
self.task = None self.task: Optional[dict] = None
self.func = None self.func = None
def handler(sender, task, **kwargs): def handler(sender, task, **kwargs):
@@ -655,11 +744,19 @@ class TestSignals:
post_execute.connect(handler) post_execute.connect(handler)
task_id = async_task("math.copysign", 1, -1, broker=broker) task_id = async_task("math.copysign", 1, -1, broker=broker)
run_cluster_once(workers=1, broker=broker) task_queue = Queue()
result_queue = Queue()
event = Event()
event.set()
pusher(task_queue, event, broker=broker)
task_queue.put("STOP")
worker(task_queue, result_queue, Value("f", -1))
result_queue.put("STOP")
monitor(result_queue, broker)
broker.delete_queue() broker.delete_queue()
assert self.signal_was_called is True assert self.signal_was_called is True
assert self.task.id == task_id assert self.task.get("id") == task_id
assert self.task.result == -1 assert self.task.get("result") == -1
post_execute.disconnect(handler) post_execute.disconnect(handler)

View File

@@ -8,7 +8,6 @@ def test_qcluster():
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.skip("broken")
def test_qmonitor(): def test_qmonitor():
call_command("qmonitor", run_once=True) call_command("qmonitor", run_once=True)
@@ -21,7 +20,6 @@ def test_qinfo():
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.skip("broken")
def test_qmemory(): def test_qmemory():
call_command("qmemory", run_once=True) call_command("qmemory", run_once=True)
call_command("qmemory", workers=True, run_once=True) call_command("qmemory", workers=True, run_once=True)

View File

@@ -0,0 +1,52 @@
import uuid
import pytest
from django_q.brokers import get_broker
from django_q.cluster import Cluster
from django_q.conf import Conf
from django_q.monitor_terminal import get_ids, info, monitor
from django_q.status import Stat
from django_q.tasks import async_task
@pytest.mark.django_db
def test_monitor(monkeypatch):
cluster_id = uuid.uuid4()
assert Stat.get(pid=0, cluster_id=cluster_id).sentinel == 0
c = Cluster()
c.start()
stats = monitor(run_once=True)
assert get_ids() is True
c.stop()
assert len(stats) > 0
found_c = False
for stat in stats:
if stat.cluster_id == c.cluster_id:
found_c = True
assert stat.uptime() > 0
assert stat.empty_queues() is True
break
assert found_c
# test lock size
monkeypatch.setattr(Conf, "ORM", "default")
b = get_broker("monitor_test")
b.enqueue("test")
b.dequeue()
assert b.lock_size() == 1
monitor(run_once=True, broker=b)
b.delete_queue()
@pytest.mark.django_db
def test_info():
info()
do_sync()
info()
for _ in range(24):
do_sync()
info()
def do_sync():
async_task("django_q.tests.tasks.countdown", 1, sync=True, save=True)

View File

@@ -2,10 +2,9 @@ import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from multiprocessing import Event, Value from multiprocessing import Event, Value
from unittest import mock from unittest import mock
from django_q.utils import localtime
import pytest
import django import django
import pytest
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.test import override_settings from django.test import override_settings
@@ -13,9 +12,11 @@ from django.utils import timezone
from django.utils.timezone import is_naive from django.utils.timezone import is_naive
from django_q.brokers import Broker, get_broker from django_q.brokers import Broker, get_broker
from django_q.helpers import run_scheduler_once, get_scheduled_tasks, save_task, run_task
from django_q.conf import Conf from django_q.conf import Conf
from queue import Queue from django_q.monitor import monitor
from django_q.pusher import pusher
from django_q.queues import Queue
from django_q.scheduler import scheduler
from django_q.tasks import Schedule, fetch from django_q.tasks import Schedule, fetch
from django_q.tasks import schedule as create_schedule from django_q.tasks import schedule as create_schedule
from django_q.tests.settings import BASE_DIR from django_q.tests.settings import BASE_DIR
@@ -23,7 +24,8 @@ from django_q.tests.testing_utilities.multiple_database_routers import (
TestingMultipleAppsDatabaseRouter, TestingMultipleAppsDatabaseRouter,
TestingReplicaDatabaseRouter, TestingReplicaDatabaseRouter,
) )
from django_q.utils import add_months from django_q.utils import add_months, localtime
from django_q.worker import worker
if django.VERSION < (4, 0): if django.VERSION < (4, 0):
# pytz is the default in django 3.2. Remove when no support for 3.2 # pytz is the default in django 3.2. Remove when no support for 3.2
@@ -86,7 +88,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
# 28th of March 2021 is the day when sunlight saving starts (at 2 am) # 28th of March 2021 is the day when sunlight saving starts (at 2 am)
monkeypatch.setattr(Conf, "TIME_ZONE", "Europe/Amsterdam") monkeypatch.setattr(Conf, "TIME_ZONE", "Europe/Amsterdam")
tz = ZoneInfo('Europe/Amsterdam') tz = ZoneInfo("Europe/Amsterdam")
broker.list_key = "scheduler_test:q" broker.list_key = "scheduler_test:q"
# Let's start a schedule at 1 am on the 27th of March. This is in AMS timezone. # Let's start a schedule at 1 am on the 27th of March. This is in AMS timezone.
# So, 2021-03-27 00:00:00 when saved (due to TZ being Amsterdam and saved in UTC) # So, 2021-03-27 00:00:00 when saved (due to TZ being Amsterdam and saved in UTC)
@@ -104,7 +106,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
) )
# Run scheduler so we get the next run date # Run scheduler so we get the next run date
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule.refresh_from_db() schedule.refresh_from_db()
# It's now the day after exactly at midnight UTC # It's now the day after exactly at midnight UTC
@@ -116,7 +118,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
assert str(next_run) == "2021-03-28 01:00:00+01:00" assert str(next_run) == "2021-03-28 01:00:00+01:00"
# Run scheduler so we get the next run date # Run scheduler so we get the next run date
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule.refresh_from_db() schedule.refresh_from_db()
next_run = schedule.next_run next_run = schedule.next_run
@@ -127,7 +129,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
assert str(next_run) == "2021-03-29 01:00:00+02:00" assert str(next_run) == "2021-03-29 01:00:00+02:00"
# Run scheduler so we get the next run date # Run scheduler so we get the next run date
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule.refresh_from_db() schedule.refresh_from_db()
next_run = schedule.next_run next_run = schedule.next_run
@@ -148,7 +150,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
) )
# Run scheduler so we get the next run date # Run scheduler so we get the next run date
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule.refresh_from_db() schedule.refresh_from_db()
next_run = schedule.next_run next_run = schedule.next_run
@@ -159,7 +161,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
assert str(next_run) == "2021-10-30 01:00:00+02:00" assert str(next_run) == "2021-10-30 01:00:00+02:00"
# Run scheduler so we get the next run date # Run scheduler so we get the next run date
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule.refresh_from_db() schedule.refresh_from_db()
next_run = schedule.next_run next_run = schedule.next_run
@@ -170,7 +172,7 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
assert str(next_run) == "2021-10-31 01:00:00+02:00" assert str(next_run) == "2021-10-31 01:00:00+02:00"
# Run scheduler so we get the next run date # Run scheduler so we get the next run date
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule.refresh_from_db() schedule.refresh_from_db()
next_run = schedule.next_run next_run = schedule.next_run
@@ -182,7 +184,6 @@ def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
assert str(next_run) == "2021-11-01 01:00:00+01:00" assert str(next_run) == "2021-11-01 01:00:00+01:00"
@pytest.mark.django_db @pytest.mark.django_db
def test_scheduler(broker, monkeypatch): def test_scheduler(broker, monkeypatch):
broker.list_key = "scheduler_test:q" broker.list_key = "scheduler_test:q"
@@ -209,15 +210,24 @@ def test_scheduler(broker, monkeypatch):
repeats=1, repeats=1,
) )
# run scheduler # run scheduler
run_scheduler_once(broker=broker) scheduler(broker=broker)
# get tasks # set up the workflow
tasks = get_scheduled_tasks(broker=broker) task_queue = Queue()
for task in tasks: stop_event = Event()
# let a worker handle them stop_event.set()
ran_task = run_task(task) # push it
# store the results pusher(task_queue, stop_event, broker=broker)
save_task(task=ran_task, broker=broker) assert task_queue.qsize() == 1
assert broker.queue_size() == 0
task_queue.put("STOP")
# let a worker handle them
result_queue = Queue()
worker(task_queue, result_queue, Value("b", -1))
assert result_queue.qsize() == 1
result_queue.put("STOP")
# store the results
monitor(result_queue)
assert result_queue.qsize() == 0
schedule = Schedule.objects.get(pk=schedule.pk) schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.repeats == 0 assert schedule.repeats == 0
assert schedule.last_run() is not None assert schedule.last_run() is not None
@@ -289,7 +299,7 @@ def test_scheduler(broker, monkeypatch):
) )
assert schedule is not None assert schedule is not None
assert schedule.last_run() is None assert schedule.last_run() is None
run_scheduler_once(broker=broker) scheduler(broker=broker)
# via model # via model
Schedule.objects.create( Schedule.objects.create(
func="django_q.tests.tasks.word_multiply", func="django_q.tests.tasks.word_multiply",
@@ -298,7 +308,7 @@ def test_scheduler(broker, monkeypatch):
schedule_type=Schedule.DAILY, schedule_type=Schedule.DAILY,
) )
# scheduler # scheduler
run_scheduler_once(broker=broker) scheduler(broker=broker)
# ONCE schedule should be deleted # ONCE schedule should be deleted
assert Schedule.objects.filter(pk=once_schedule.pk).exists() is False assert Schedule.objects.filter(pk=once_schedule.pk).exists() is False
# Catch up On # Catch up On
@@ -312,12 +322,12 @@ def test_scheduler(broker, monkeypatch):
next_run=timezone.now() - timedelta(hours=12), next_run=timezone.now() - timedelta(hours=12),
repeats=-1, repeats=-1,
) )
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule = Schedule.objects.get(pk=schedule.pk) schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.next_run < now assert schedule.next_run < now
# Catch up off # Catch up off
monkeypatch.setattr(Conf, "CATCH_UP", False) monkeypatch.setattr(Conf, "CATCH_UP", False)
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule = Schedule.objects.get(pk=schedule.pk) schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.next_run > now assert schedule.next_run > now
# Done # Done
@@ -330,7 +340,7 @@ def test_scheduler(broker, monkeypatch):
word="catch_up", word="catch_up",
schedule_type=Schedule.BIMONTHLY, schedule_type=Schedule.BIMONTHLY,
) )
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule = Schedule.objects.get(pk=schedule.pk) schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.next_run.date() == add_months(timezone.now(), 2).date() assert schedule.next_run.date() == add_months(timezone.now(), 2).date()
@@ -341,7 +351,7 @@ def test_scheduler(broker, monkeypatch):
word="catch_up", word="catch_up",
schedule_type=Schedule.BIWEEKLY, schedule_type=Schedule.BIWEEKLY,
) )
run_scheduler_once(broker=broker) scheduler(broker=broker)
schedule = Schedule.objects.get(pk=schedule.pk) schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.next_run.date() == (timezone.now() + timedelta(weeks=2)).date() assert schedule.next_run.date() == (timezone.now() + timedelta(weeks=2)).date()
broker.delete_queue() broker.delete_queue()
@@ -359,12 +369,16 @@ def test_scheduler(broker, monkeypatch):
repeats=1, repeats=1,
) )
# run scheduler # run scheduler
run_scheduler_once(broker=broker) scheduler(broker=broker)
# set up the workflow
task_queue = Queue()
stop_event = Event()
stop_event.set()
# push it # push it
tasks = get_scheduled_tasks(broker=broker) pusher(task_queue, stop_event, broker=broker)
# queue must be empty # queue must be empty
assert len(tasks) == 0 assert task_queue.qsize() == 0
monkeypatch.setattr(Conf, "CLUSTER_NAME", "default") monkeypatch.setattr(Conf, "CLUSTER_NAME", "default")
# create a schedule on the same cluster # create a schedule on the same cluster
@@ -379,19 +393,23 @@ def test_scheduler(broker, monkeypatch):
repeats=1, repeats=1,
) )
# run scheduler # run scheduler
run_scheduler_once(broker=broker) scheduler(broker=broker)
# set up the workflow
task_queue = Queue()
stop_event = Event()
stop_event.set()
# push it # push it
tasks = get_scheduled_tasks(broker=broker) pusher(task_queue, stop_event, broker=broker)
# queue must contain a task # queue must contain a task
assert len(tasks) == 1 assert task_queue.qsize() == 1
@pytest.mark.django_db @pytest.mark.django_db
def test_intended_schedule_kwarg(broker, monkeypatch): def test_intended_schedule_kwarg(broker, monkeypatch):
broker.list_key = "scheduler_test:q" broker.list_key = "scheduler_test:q"
broker.delete_queue() broker.delete_queue()
run_date = timezone.now()-timedelta(hours=1) run_date = timezone.now() - timedelta(hours=1)
schedule = create_schedule( schedule = create_schedule(
"math.copysign", "math.copysign",
1, 1,
@@ -401,36 +419,40 @@ def test_intended_schedule_kwarg(broker, monkeypatch):
schedule_type=Schedule.HOURLY, schedule_type=Schedule.HOURLY,
repeats=1, repeats=1,
next_run=run_date, next_run=run_date,
intended_date_kwarg='intended_date', intended_date_kwarg="intended_date",
) )
assert schedule.last_run() is None assert schedule.last_run() is None
assert schedule.intended_date_kwarg == 'intended_date' assert schedule.intended_date_kwarg == "intended_date"
# run scheduler # run scheduler
run_scheduler_once(broker=broker) scheduler(broker=broker)
# set up the workflow # set up the workflow
scheduled_tasks = get_scheduled_tasks(broker=broker) task_queue = Queue()
assert len(scheduled_tasks) == 1 stop_event = Event()
task = scheduled_tasks[0] stop_event.set()
assert 'intended_date' in task.kwargs # push it
assert task.kwargs['intended_date'] == run_date.isoformat() pusher(task_queue, stop_event, broker=broker)
assert task_queue.qsize() == 1
task = task_queue.get()
assert "intended_date" in task["kwargs"]
assert task["kwargs"]["intended_date"] == run_date.isoformat()
# @override_settings( @override_settings(
# DATABASE_ROUTERS=REPLICA_DATABASE_ROUTERS, DATABASES=REPLICA_DATABASES DATABASE_ROUTERS=REPLICA_DATABASE_ROUTERS, DATABASES=REPLICA_DATABASES
# ) )
# @pytest.mark.django_db @pytest.mark.django_db
# def test_scheduler_atomic_must_specify_the_write_db( def test_scheduler_atomic_must_specify_the_write_db(
# orm_broker: Broker, orm_broker: Broker,
# ): ):
# """ """
# GIVEN a environment with a read/write configured replica database GIVEN a environment with a read/write configured replica database
# WHEN the scheduler is called WHEN the scheduler is called
# THEN the transaction must be called with the write database. THEN the transaction must be called with the write database.
# """ """
# broker = get_broker(list_key="scheduler_test:q") broker = get_broker(list_key="scheduler_test:q")
# with mock.patch("django_q.scheduler.db.transaction") as mocked_db: with mock.patch("django_q.cluster.db.transaction") as mocked_db:
# run_scheduler_once(broker=broker) scheduler(broker=broker)
# mocked_db.atomic.assert_called_with(using="writable") mocked_db.atomic.assert_called_with(using="writable")
@override_settings( @override_settings(
@@ -447,7 +469,7 @@ def test_scheduler_atomic_must_specify_the_database_based_on_router_redirection(
""" """
broker = get_broker(list_key="scheduler_test:q") broker = get_broker(list_key="scheduler_test:q")
with mock.patch("django_q.cluster.db.transaction") as mocked_db: with mock.patch("django_q.cluster.db.transaction") as mocked_db:
run_scheduler_once(broker=broker) scheduler(broker=broker)
mocked_db.atomic.assert_called_with(using="default") mocked_db.atomic.assert_called_with(using="default")

38
django_q/timeout.py Normal file
View File

@@ -0,0 +1,38 @@
import signal
from django.utils.translation import gettext_lazy as _
from django_q.conf import logger
from .exceptions import TimeoutException
class TimeoutHandler:
def __init__(self, timeout: int):
self._timeout = timeout
def raise_timeout_exception(self, signum, frame):
raise TimeoutException(
f"Task exceeded maximum timeout value ({self._timeout} seconds)"
)
def __enter__(self):
# if the timeout is -1, then there is no timeout and the task will always keep running until it's done or manually killed
if self._timeout == -1:
return
try:
signal.signal(signal.SIGALRM, self.raise_timeout_exception)
except ValueError: # ValueError is raised for Windows users
logger.debug(_("SIGALARM is not available on your platform"))
signal.alarm(self._timeout)
def __exit__(self, exc_type, exc_value, traceback):
if self._timeout == -1:
return
"""When getting out of the timeout, reset the alarm, so it won't trigger"""
try:
signal.alarm(0)
signal.signal(signal.SIGALRM, signal.SIG_DFL)
except ValueError: # ValueError is raised for Windows users
logger.debug(_("SIGALARM is not available on your platform"))

View File

@@ -1,11 +1,11 @@
from datetime import datetime
from django import db
import calendar import calendar
from datetime import date import inspect
from datetime import date, datetime
import django import django
from django.utils import timezone from django import db
from django.conf import settings from django.conf import settings
from django.utils import timezone
from django_q.conf import Conf, logger from django_q.conf import Conf, logger
@@ -45,10 +45,22 @@ def add_years(d, years):
return d.replace(year=new_date.year, month=new_date.month, day=new_date.day) return d.replace(year=new_date.year, month=new_date.month, day=new_date.day)
def get_func_repr(func):
# convert func to string
if inspect.isfunction(func):
return f"{func.__module__}.{func.__name__}"
elif inspect.ismethod(func) and hasattr(func.__self__, "__name__"):
return (
f"{func.__self__.__module__}." f"{func.__self__.__name__}.{func.__name__}"
)
else:
return str(func) if func else None
def localtime(value=None) -> datetime: def localtime(value=None) -> datetime:
"""Override for timezone.localtime to deal with naive times and local times""" """Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ: if settings.USE_TZ:
if django.VERSION >= (4, 0) and settings.USE_DEPRECATED_PYTZ: if django.VERSION >= (4, 0) and getattr(settings, "USE_DEPRECATED_PYTZ", False):
import pytz import pytz
convert_to_tz = pytz.timezone(Conf.TIME_ZONE) convert_to_tz = pytz.timezone(Conf.TIME_ZONE)
@@ -74,4 +86,3 @@ def close_old_django_connections():
) )
else: else:
db.close_old_connections() db.close_old_connections()

View File

@@ -1,221 +1,141 @@
import multiprocessing import pydoc
from queue import Queue
from queue import Empty
from typing import Optional, Tuple, Union
from django_q.queue_task import QueueTask
from django.utils import timezone
import traceback import traceback
from multiprocessing import Value
from multiprocessing.process import current_process
from multiprocessing.queues import Queue
from multiprocessing import Process, Value, current_process from django import core
from django_q.utils import close_old_django_connections from django.apps.registry import apps
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_q.conf import Conf, logger, setproctitle, error_reporter, resource, psutil
from django_q.signals import pre_execute try:
from django_q.exceptions import TimeoutException, TimeoutHandler apps.check_apps_ready()
from django_q.process_manager import ProcessManager except core.exceptions.AppRegistryNotReady:
import django
django.setup()
from django_q.conf import Conf, error_reporter, logger, resource, setproctitle
from django_q.exceptions import TimeoutException
from django_q.signals import post_spawn, pre_execute
from django_q.timeout import TimeoutHandler
from django_q.utils import close_old_django_connections, get_func_repr
try:
import psutil
except ImportError:
psutil = None
try:
import setproctitle
except ModuleNotFoundError:
setproctitle = None
def worker(
task_queue: Queue, result_queue: Queue, timer: Value, timeout: int = Conf.TIMEOUT
):
"""
Takes a task from the task queue, tries to execute it and puts the result back in
the result queue
:param timeout: number of seconds wait for a worker to finish.
:type task_queue: multiprocessing.Queue
:type result_queue: multiprocessing.Queue
:type timer: multiprocessing.Value
"""
proc_name = current_process().name
logger.info(
_("%(proc_name)s ready for work at %(id)s")
% {"proc_name": proc_name, "id": current_process().pid}
)
post_spawn.send(sender="django_q", proc_name=proc_name)
if setproctitle:
setproctitle.setproctitle(f"qcluster {proc_name} idle")
task_count = 0
if timeout is None:
timeout = -1
# Start reading the task queue
for task in iter(task_queue.get, "STOP"):
result = None
timer.value = -1 # Idle
task_count += 1
f = task["func"]
class Worker(ProcessManager):
def spawn_process(self) -> Process:
"""
:type target: function or class
"""
self.status = Value("i", Worker.Status.IDLE.value)
self.manager_pipe, worker_process_pipe = multiprocessing.Pipe(duplex=True)
p = WorkerProcess(args=(self.status, worker_process_pipe))
p.daemon = Conf.DAEMONIZE_WORKERS
p.start()
return p
def start_task(self, task) -> None:
# send task to worker
try:
self.manager_pipe.send(task)
except BrokenPipeError:
# recycle process if pipe is broken
self.status.value = ProcessManager.Status.RECYCLE.value
class Pool:
"""This will manager the individual workers"""
def __init__(self, workers=Conf.WORKERS):
self.amount_workers = workers
self.workers = []
self.task_queue = Queue()
self.start_workers()
def start_workers(self):
for __ in range(self.amount_workers):
self.workers.append(Worker())
def get_worker(self, id) -> Optional[Worker]:
worker = next((worker for worker in self.workers if worker.id == id), None)
if worker is None:
logger.error("Couldn't find worker")
return
return worker
@property
def is_healthy(self):
"""Checks if all workers are still operating"""
return all(worker.is_alive for worker in self.workers)
@property
def is_idle(self):
"""Checks if all workers are idle"""
return all(worker.is_idle for worker in self.workers)
@property
def is_done(self):
"""Checks if all workers are idle and task queue is empty"""
return self.is_idle and self.task_queue.empty()
def reincarnate_stopped_workers(self):
"""Reincarnates workers that are not alive anymore"""
stopped_workers = [worker for worker in self.workers if not worker.is_alive]
for worker in stopped_workers:
worker.reincarnate_process()
def add_task(self, task):
self.task_queue.put(task)
def get_done_workers(self):
"""Worker tasks that have been completed, but need to be saved to cache/db - to be processed by monitor worker"""
return [worker for worker in self.workers if worker.is_done]
def mark_workers_idle(self, worker_ids):
"""Mark workers idle when they are ready to be used again"""
for worker_id in worker_ids:
# We are going to process the result, mark them idle, so they can be used for a different task
worker = self.get_worker(id=worker_id)
if worker is not None:
worker.mark_idle()
def delegate_tasks(self):
available_workers = [worker for worker in self.workers if worker.is_idle]
for worker in available_workers:
try:
task = self.task_queue.get_nowait()
except Empty:
# if the queue is empty, then just stop
break
worker.start_task(task)
class WorkerProcess(Process):
@staticmethod
def run_task(task) -> Tuple[QueueTask, bool]:
# signal execution
pre_execute.send(sender="django_q", func=task.func, task=task)
task.started_at = timezone.now()
try:
with TimeoutHandler(timeout=task.timeout):
func = task.callable_func()
res = func(*task.args, **task.kwargs)
result = res
except (TimeoutException, Exception) as e:
if isinstance(e, TimeoutException):
task.status = QueueTask.Status.TIMEOUT
else:
task.status = QueueTask.Status.FAILED
result = f"{e} : {traceback.format_exc()}"
if error_reporter:
error_reporter.report()
if task.sync:
raise
return task
else:
# succeeded
task.status = QueueTask.Status.SUCCESS
finally:
task.result = result
task.finished_at = timezone.now()
return task
def __init__(self, group=None, name=None, args=(), kwargs={}, daemon=None):
target = self.processing_tasks
super().__init__(group=group, target=target, name=name, args=args, kwargs=kwargs, daemon=daemon)
def mark_ready(self):
self.process_name = current_process().name
self.process_id = current_process().pid
self.task_count = 0
logger.info(
_("%(proc_name)s ready for work at %(id)s")
% {"proc_name": self.process_name, "id": self.process_id}
)
def mark_start_task(self, task):
# Log task creation and set process name # Log task creation and set process name
task_desc = ( # Get the function from the task
_("%(proc_name)s processing %(task_name)s '%(func_name)s'") func_name = get_func_repr(f)
% { task_name = task["name"]
"proc_name": self.process_name, task_desc = _("%(proc_name)s processing %(task_name)s '%(func_name)s'") % {
"func_name": task.func_name, "proc_name": proc_name,
"task_name": task.name, "func_name": func_name,
} "task_name": task_name,
) }
if task.group is not None: if "group" in task:
task_desc += f" [{task.group}]" task_desc += f" [{task['group']}]"
logger.info(task_desc) logger.info(task_desc)
if setproctitle: if setproctitle:
proc_title = f"qcluster {self.process_name} processing {task.name} '{task.func_name}'" proc_title = f"qcluster {proc_name} processing {task_name} '{func_name}'"
if task.group is not None: if "group" in task:
proc_title += f" [{task.group}]" proc_title += f" [{task['group']}]"
setproctitle.setproctitle(proc_title) setproctitle.setproctitle(proc_title)
def processing_tasks(self, status: Value, pipe): # if it's not an instance try to get it from the string
self.mark_ready() if not callable(f):
while True: # locate() returns None if f cannot be loaded
task = pipe.recv() f = pydoc.locate(f)
if task == "STOP": close_old_django_connections()
logger.info(f"Worker {self.process_name} stopped processing") timer_value = task.pop("timeout", timeout)
# signal execution
pre_execute.send(sender="django_q", func=f, task=task)
# execute the payload
timer.value = timer_value # Busy
if timer.value != -1:
timer.value += 3 # Add buffer so that guard doesn't kill the process on timeout before it gets processed
timeout_error = False
try:
if f is None:
# raise a meaningfull error if task["func"] is not a valid function
raise ValueError(f"Function {task['func']} is not defined")
with TimeoutHandler(timer_value):
res = f(*task["args"], **task["kwargs"])
result = (res, True)
except (Exception, TimeoutException) as e:
if isinstance(e, TimeoutException):
timeout_error = True
result = (f"{e} : {traceback.format_exc()}", False)
if error_reporter:
error_reporter.report()
if task.get("sync", False):
raise
with timer.get_lock():
# Process result
task["result"] = result[0]
task["success"] = result[1]
task["stopped"] = timezone.now()
result_queue.put(task)
if timeout_error:
# force destroy process due to timeout
timer.value = 0
break break
# got a new task, let's mark it starting
self.mark_start_task(task)
# make sure the function actually exists, before we try to run it timer.value = -1 # Idle
try:
if not task.is_callable:
raise ValueError(f"Function {task.func_name} is not defined")
except Exception as e:
result = (f"{e} : {traceback.format_exc()}", False)
if error_reporter:
error_reporter.report()
if task.sync:
raise
# stop here, move on to the next one
continue
close_old_django_connections()
status.value = ProcessManager.Status.BUSY.value
task = WorkerProcess.run_task(task)
# Add task towards total
self.task_count += 1
# Set to DONE so main process can pick it up
status.value = ProcessManager.Status.DONE.value
if setproctitle: if setproctitle:
setproctitle.setproctitle(f"qcluster {self.process_name} completed with task") setproctitle.setproctitle(f"qcluster {proc_name} idle")
# Recycle
# Recreate a new process if this task has had the max amount of runs or exceeded resources if task_count == Conf.RECYCLE or rss_check():
if self.task_count == Conf.RECYCLE or self.rss_check(): timer.value = -2 # Recycled
status.value = ProcessManager.Status.RECYCLE
break break
logger.info(_("%(proc_name)s stopped doing work") % {"proc_name": proc_name})
pipe.send(task)
def rss_check(self): def rss_check():
if Conf.MAX_RSS: if Conf.MAX_RSS:
if resource: if resource:
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss >= Conf.MAX_RSS return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss >= Conf.MAX_RSS
elif psutil: elif psutil:
return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024 return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024
return False return False

View File

@@ -49,8 +49,9 @@ Reference
:param str group: an optional group name. :param str group: an optional group name.
:param bool cached: run this against the cache backend :param bool cached: run this against the cache backend
:param bool sync: execute this inline instead of asynchronous :param bool sync: execute this inline instead of asynchronous
:param broker: an optional broker instance
.. py:class:: Chain(chain=None, group=None, cached=Conf.CACHED, sync=Conf.SYNC) .. py:class:: Chain(chain=None, group=None, cached=Conf.CACHED, sync=Conf.SYNC, broker=None)
A sequential chain of tasks. Acts as a convenient wrapper for :func:`async_chain` A sequential chain of tasks. Acts as a convenient wrapper for :func:`async_chain`
You can pass the task chain at construction or you can append individual tasks before running them. You can pass the task chain at construction or you can append individual tasks before running them.
@@ -59,6 +60,8 @@ Reference
:param str group: an optional group name. :param str group: an optional group name.
:param bool cached: run this against the cache backend :param bool cached: run this against the cache backend
:param bool sync: execute this inline instead of asynchronous :param bool sync: execute this inline instead of asynchronous
:param bool sync: execute this inline instead of asynchronous
:param broker: an optional broker instance
.. py:method:: append(func, *args, **kwargs) .. py:method:: append(func, *args, **kwargs)

View File

@@ -11,36 +11,36 @@ Start your cluster using Django's ``manage.py`` command::
You should see the cluster starting :: You should see the cluster starting ::
10:57:40 [Q] INFO Q Cluster-31781 starting. 10:57:40 [Q] INFO Q Cluster freddie-uncle-twenty-ten starting.
10:57:40 [Q] INFO Process-1:1 ready for work at 31784 10:57:40 [Q] INFO Process-ede257774c4444c980ab479f10947acc ready for work at 31784
10:57:40 [Q] INFO Process-1:2 ready for work at 31785 10:57:40 [Q] INFO Process-ed580482da3f42968230baa2e4253e42 ready for work at 31785
10:57:40 [Q] INFO Process-1:3 ready for work at 31786 10:57:40 [Q] INFO Process-8a370dc2bc1d49aa9864e517c9895f74 ready for work at 31786
10:57:40 [Q] INFO Process-1:4 ready for work at 31787 10:57:40 [Q] INFO Process-74912f9844264d1397c6e54476b530c0 ready for work at 31787
10:57:40 [Q] INFO Process-1:5 ready for work at 31788 10:57:40 [Q] INFO Process-b00edb26c6074a6189e5696c60aeb35b ready for work at 31788
10:57:40 [Q] INFO Process-1:6 ready for work at 31789 10:57:40 [Q] INFO Process-b0862965db04479f9784a26639ee51e0 ready for work at 31789
10:57:40 [Q] INFO Process-1:7 ready for work at 31790 10:57:40 [Q] INFO Process-7e8abbb8ca2d4d9bb20a937dd5e2872b ready for work at 31790
10:57:40 [Q] INFO Process-1:8 ready for work at 31791 10:57:40 [Q] INFO Process-b0862965db04479f9784a26639ee51e0 ready for work at 31791
10:57:40 [Q] INFO Process-1:9 monitoring at 31792 10:57:40 [Q] INFO Process-67fa9461ac034736a766cd813f617e62 monitoring at 31792
10:57:40 [Q] INFO Process-1 guarding cluster at 31783 10:57:40 [Q] INFO Process-eac052c646b2459797cee98bdb84c85d guarding cluster at 31783
10:57:40 [Q] INFO Process-1:10 pushing tasks at 31793 10:57:40 [Q] INFO Process-5d98deb19b1e4b2da2ef1e5bd6824f75 pushing tasks at 31793
10:57:40 [Q] INFO Q Cluster-31781 running. 10:57:40 [Q] INFO Q Cluster freddie-uncle-twenty-ten running.
Stopping the cluster with ctrl-c or either the ``SIGTERM`` and ``SIGKILL`` signals, will initiate the :ref:`stop_procedure`:: Stopping the cluster with ctrl-c or either the ``SIGTERM`` and ``SIGKILL`` signals, will initiate the :ref:`stop_procedure`::
16:44:12 [Q] INFO Q Cluster-31781 stopping. 16:44:12 [Q] INFO Q Cluster freddie-uncle-twenty-ten stopping.
16:44:12 [Q] INFO Process-1 stopping cluster processes 16:44:12 [Q] INFO Process-eac052c646b2459797cee98bdb84c85d stopping cluster processes
16:44:13 [Q] INFO Process-1:10 stopped pushing tasks 16:44:13 [Q] INFO Process-5d98deb19b1e4b2da2ef1e5bd6824f75 stopped pushing tasks
16:44:13 [Q] INFO Process-1:6 stopped doing work 16:44:13 [Q] INFO Process-b0862965db04479f9784a26639ee51e0 stopped doing work
16:44:13 [Q] INFO Process-1:4 stopped doing work 16:44:13 [Q] INFO Process-7e8abbb8ca2d4d9bb20a937dd5e2872b stopped doing work
16:44:13 [Q] INFO Process-1:1 stopped doing work 16:44:13 [Q] INFO Process-b0862965db04479f9784a26639ee51e0 stopped doing work
16:44:13 [Q] INFO Process-1:5 stopped doing work 16:44:13 [Q] INFO Process-b00edb26c6074a6189e5696c60aeb35b stopped doing work
16:44:13 [Q] INFO Process-1:7 stopped doing work 16:44:13 [Q] INFO Process-74912f9844264d1397c6e54476b530c0 stopped doing work
16:44:13 [Q] INFO Process-1:3 stopped doing work 16:44:13 [Q] INFO Process-8a370dc2bc1d49aa9864e517c9895f74 stopped doing work
16:44:13 [Q] INFO Process-1:8 stopped doing work 16:44:13 [Q] INFO Process-ed580482da3f42968230baa2e4253e42 stopped doing work
16:44:13 [Q] INFO Process-1:2 stopped doing work 16:44:13 [Q] INFO Process-ede257774c4444c980ab479f10947acc stopped doing work
16:44:14 [Q] INFO Process-1:9 stopped monitoring results 16:44:14 [Q] INFO Process-67fa9461ac034736a766cd813f617e62 stopped monitoring results
16:44:15 [Q] INFO Q Cluster-31781 has stopped. 16:44:15 [Q] INFO Q Cluster freddie-uncle-twenty-ten has stopped.
The number of workers, optional timeouts, recycles and cpu_affinity can be controlled via the :doc:`configure` settings. The number of workers, optional timeouts, recycles and cpu_affinity can be controlled via the :doc:`configure` settings.

View File

@@ -73,9 +73,9 @@ author = "Ilan Steemers, Stan Triepels"
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = "1.5" version = "1.7"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = "1.5.1" release = "1.7.2"
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View File

@@ -79,6 +79,8 @@ timeout
The number of seconds a worker is allowed to spend on a task before it's terminated. Defaults to ``None``, meaning it will never time out. The number of seconds a worker is allowed to spend on a task before it's terminated. Defaults to ``None``, meaning it will never time out.
Set this to something that makes sense for your project. Can be overridden for individual tasks. Set this to something that makes sense for your project. Can be overridden for individual tasks.
Note: for systems that don't have `SIGALRM` available (e.g. Windows), it will not raise an error properly. It will kill the task, but it will keep retrying until it finishes within the given time.
See :ref:`retry` for details how to set values for timeout and retry. See :ref:`retry` for details how to set values for timeout and retry.
.. _time_zone: .. _time_zone:
@@ -348,7 +350,7 @@ To use MongoDB as a message broker you simply provide the connection information
} }
} }
The ``mongo`` dictionary can contain any of the parameters exposed by pymongo's `MongoClient <https://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`__ The ``mongo`` dictionary can contain any of the parameters exposed by pymongo's `MongoClient <https://pymongo.readthedocs.io/en/stable/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`__
If you want to use a mongodb uri, you can supply it as the ``host`` parameter. If you want to use a mongodb uri, you can supply it as the ``host`` parameter.
mongo_db mongo_db

View File

@@ -1,4 +1,3 @@
version: "3.0"
services: services:
docs: docs:
container_name: djangoq2-docs container_name: djangoq2-docs

View File

@@ -328,7 +328,7 @@ Requires cache to be enabled. Save file in your Django project's root directory
# All django stuff has to come after the setup: # All django stuff has to come after the setup:
django.setup() django.setup()
from django_q.monitor import Stat from django_q.status import Stat
from django_q.conf import Conf from django_q.conf import Conf
# Set host and port settings # Set host and port settings

View File

@@ -27,7 +27,7 @@ Features
- Rollbar and Sentry support - Rollbar and Sentry support
Django Q2 is tested with: Python 3.8, 3.9, 3.10, and 3.11. Works with Django 3.2.x and 4.1.x. Django Q2 is tested with: Python 3.8, 3.9, 3.10, 3.11 and 3.12. Works with Django 4.2.x and 5.0.x
Currently available in English, German and French. Currently available in English, German and French.

View File

@@ -24,15 +24,31 @@ Installation
$ python manage.py qcluster $ python manage.py qcluster
Migrate from Django-Q to Django-Q2
----------------------------------
If you have an application with django-q running right now, you can simply swap the libraries and you should be good to go.::
$ pip uninstall django-q # you might have to uninstall django-q add-ons as well
$ pip install django-q2
Then migrate the database to get the latest tables/fields::
$ python manage.py migrate
Requirements Requirements
------------ ------------
Django Q2 is tested for Python 3.8, 3.9, 3.10 and 3.11 Django Q2 is tested for Python 3.8, 3.9, 3.10, 3.11 and 3.12
- `Django <https://www.djangoproject.com>`__ - `Django <https://www.djangoproject.com>`__
Django Q2 aims to use as much of Django's standard offerings as possible. Django Q2 aims to use as much of Django's standard offerings as possible.
The code is tested against Django versions `3.2.x` and `4.1.x`. The code is tested against Django versions `3.2.x`, `4.1.x`, `4.2.x` and `5.0.x`.
- `Django-picklefield <https://github.com/gintas/django-picklefield>`__ - `Django-picklefield <https://github.com/gintas/django-picklefield>`__
@@ -41,6 +57,10 @@ Django Q2 is tested for Python 3.8, 3.9, 3.10 and 3.11
Optional Optional
~~~~~~~~ ~~~~~~~~
- `Blessed <https://github.com/jquast/blessed>`__ is used to display the statistics in the terminal::
$ pip install blessed
- `Redis-py <https://github.com/andymccurdy/redis-py>`__ client by Andy McCurdy is used to interface with both the Redis:: - `Redis-py <https://github.com/andymccurdy/redis-py>`__ client by Andy McCurdy is used to interface with both the Redis::
$ pip install redis $ pip install redis
@@ -95,11 +115,11 @@ Add-ons
------- -------
- `django-q-rollbar <https://github.com/danielwelch/django-q-rollbar>`__ is a Rollbar error reporter:: - `django-q-rollbar <https://github.com/danielwelch/django-q-rollbar>`__ is a Rollbar error reporter::
$ pip install django-q[rollbar] $ pip install django-q2[rollbar]
- `django-q-sentry <https://github.com/danielwelch/django-q-sentry>`__ is a Sentry error reporter:: - `django-q-sentry <https://github.com/danielwelch/django-q-sentry>`__ is a Sentry error reporter::
$ pip install django-q[sentry] $ pip install django-q2[sentry]
- `django-q-email <https://github.com/joeyespo/django-q-email>`__ is a compatible Django email backend that will automatically async queue your emails. - `django-q-email <https://github.com/joeyespo/django-q-email>`__ is a compatible Django email backend that will automatically async queue your emails.
@@ -125,7 +145,7 @@ Other known issues are:
Python Python
~~~~~~ ~~~~~~
Current tests are performed with 3.8, 3.9, 3.10 and 3.11 Current tests are performed with 3.8, 3.9, 3.10, 3.11 and 3.12
If you do encounter any regressions with earlier versions, please submit an issue on `github <https://github.com/GDay/django-q2>`__ If you do encounter any regressions with earlier versions, please submit an issue on `github <https://github.com/GDay/django-q2>`__
Open-source packages Open-source packages
@@ -135,8 +155,8 @@ You can reference the `requirements <https://github.com/GDay/django-q2/blob/mast
Django Django
~~~~~~ ~~~~~~
We strive to be compatible with last two major version of Django. We strive to be compatible with the last two major version of Django.
At the moment this means we support the 3.2.x and 4.1.x releases. At the moment this means we support the 3.2.x, 4.1.x, 4.2.x and 5.0.x releases.
Since we are now no longer supporting Python 2, we can also not support older versions of Django that do not support Python >= 3.6 Since we are now no longer supporting Python 2, we can also not support older versions of Django that do not support Python >= 3.8
For this you can always use older releases, but they are no longer maintained. For this you can always use older releases, but they are no longer maintained.

View File

@@ -3,6 +3,10 @@ Monitor
.. py:currentmodule::django_q.monitor .. py:currentmodule::django_q.monitor
.. warning::
Blessed needs to be installed to get this to work! See: https://pypi.org/project/blessed/
The cluster monitor shows live information about all the Q clusters connected to your project. The cluster monitor shows live information about all the Q clusters connected to your project.
Start the monitor with Django's `manage.py` command:: Start the monitor with Django's `manage.py` command::
@@ -107,7 +111,7 @@ You can check the status of your clusters straight from your code with the :clas
.. code:: python .. code:: python
from django_q.monitor import Stat from django_q.status import Stat
for stat in Stat.get_all(): for stat in Stat.get_all():
print(stat.cluster_id, stat.status) print(stat.cluster_id, stat.status)

View File

@@ -13,6 +13,12 @@ Before enqueuing a task
The ``django_q.signals.pre_enqueue`` signal is emitted before a task is The ``django_q.signals.pre_enqueue`` signal is emitted before a task is
enqueued. The task dictionary is given as the ``task`` argument. enqueued. The task dictionary is given as the ``task`` argument.
After spawning a worker process
"""""""""""""""""""""""""""""""
The ``django_q.signals.post_spawn`` signal is emitted after a worker process has
spawned. The process name is given as the ``proc_name`` argument (string).
Before executing a task Before executing a task
""""""""""""""""""""""" """""""""""""""""""""""
@@ -37,7 +43,7 @@ Connecting to a Django Q2 signal is done the same as any other Django
signal:: signal::
from django.dispatch import receiver from django.dispatch import receiver
from django_q.signals import pre_enqueue, pre_execute, post_execute from django_q.signals import pre_enqueue, pre_execute, post_execute, post_spawn
@receiver(pre_enqueue) @receiver(pre_enqueue)
def my_pre_enqueue_callback(sender, task, **kwargs): def my_pre_enqueue_callback(sender, task, **kwargs):
@@ -51,4 +57,8 @@ signal::
def my_post_execute_callback(sender, task, **kwargs): def my_post_execute_callback(sender, task, **kwargs):
print(f"Task {task['name']} was executed with result {task['result']}") print(f"Task {task['name']} was executed with result {task['result']}")
@receiver(post_spawn)
def my_post_spawn_callback(sender, proc_name, **kwargs):
print(f"Process {proc_name} has spawned")

View File

@@ -75,6 +75,10 @@ broker
"""""" """"""
A broker instance, in case you want to control your own connections. A broker instance, in case you want to control your own connections.
cluster
""""""
The name of the cluster. Only useful if you are using [alternative queues](https://django-q2.readthedocs.io/en/master/cluster.html#multiple-queues).
task_name task_name
""""""""" """""""""
@@ -121,6 +125,7 @@ Optionally you can use the :class:`AsyncTask` class to instantiate a task and ke
a.run() a.run()
# wait indefinitely for the result and print it # wait indefinitely for the result and print it
# don't let the task return `None` or it will wait indefinitely
print(a.result(wait=-1)) print(a.result(wait=-1))
# change the args # change the args
@@ -240,7 +245,7 @@ Reference
--------- ---------
.. py:function:: async_task(func, *args, hook=None, group=None, timeout=None,\ .. py:function:: async_task(func, *args, hook=None, group=None, timeout=None,\
save=None, sync=False, cached=False, broker=None, q_options=None, **kwargs) save=None, sync=False, cached=False, broker=None, cluster=None, q_options=None, **kwargs)
Puts a task in the cluster queue Puts a task in the cluster queue
@@ -254,6 +259,7 @@ Reference
:param bool sync: If set to True, async_task will simulate a task execution :param bool sync: If set to True, async_task will simulate a task execution
:param cached: Output the result to the cache backend. Bool or timeout in seconds :param cached: Output the result to the cache backend. Bool or timeout in seconds
:param broker: Optional broker connection from :func:`brokers.get_broker` :param broker: Optional broker connection from :func:`brokers.get_broker`
:param cluster: Optional cluster name if using alternative queues
:param dict q_options: Options dict, overrides option keywords :param dict q_options: Options dict, overrides option keywords
:param dict kwargs: Keyword arguments for the task function :param dict kwargs: Keyword arguments for the task function
:returns: The uuid of the task :returns: The uuid of the task
@@ -264,7 +270,7 @@ Reference
Gets the result of a previously executed task Gets the result of a previously executed task
:param str task_id: the uuid or name of the task :param str task_id: the uuid or name of the task
:param int wait: optional milliseconds to wait for a result. -1 for indefinite :param int wait: optional milliseconds to wait for a result. -1 for indefinite, but be sure the result will not be `None` otherwise it will wait indefinitely!
:param bool cached: run this against the cache backend. :param bool cached: run this against the cache backend.
:returns: The result of the executed task :returns: The result of the executed task

View File

16
exampleproject/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for exampleproject project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "exampleproject.settings")
application = get_asgi_application()

134
exampleproject/settings.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Django settings for exampleproject project.
Generated by 'django-admin startproject' using Django 5.1.1.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-)oouh93bjg+c=b!l5-*w)7et1l+!3nmp223rr^1r4v#jn&ow3f"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_q",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "exampleproject.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "exampleproject.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
Q_CLUSTER = {
"name": "DjangORM",
"workers": 4,
"timeout": 90,
"retry": 120,
"queue_limit": 50,
"bulk": 10,
"orm": "default",
}

27
exampleproject/urls.py Normal file
View File

@@ -0,0 +1,27 @@
"""
URL configuration for exampleproject project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from exampleproject import views
urlpatterns = [
path("admin/", admin.site.urls),
path("new_task/", views.add_task, name="add_task"),
path("result/<slug:task_id>/", views.get_result, name="get_result"),
]

31
exampleproject/views.py Normal file
View File

@@ -0,0 +1,31 @@
import time
from django.http import HttpResponse
from django.urls import reverse
from django_q import tasks
# internal function to be called with django_q
def new_task(run_for_minutes):
print("Task started")
time.sleep(run_for_minutes)
print("Task done")
return True
def add_task(request):
task_id = tasks.async_task(new_task, 5)
result_url = reverse("get_result", args=[task_id])
return HttpResponse(
f"Added async task with <a href='{result_url}'>Go to results</a>"
)
def get_result(request, task_id):
task = tasks.fetch(task_id)
if not task:
msg = "Task running... please refresh after some time"
else:
msg = f"Async task result: {task.result}"
return HttpResponse(msg)

16
exampleproject/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for exampleproject project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "exampleproject.settings")
application = get_wsgi_application()

1591
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry] [tool.poetry]
name = "django-q2" name = "django-q2"
version = "1.5.1" version = "1.7.2"
packages = [ packages = [
{ include = "django_q" }, { include = "django_q" },
] ]
@@ -30,6 +34,7 @@ classifiers = [
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP',
'Topic :: System :: Distributed Computing', 'Topic :: System :: Distributed Computing',
'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Software Development :: Libraries :: Python Modules',
@@ -44,8 +49,8 @@ include = ['CHANGELOG.md']
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.8, <4" python = ">=3.8,<4"
django = ">=3.2" django = ">=4.2, <6"
django-picklefield = "^3.1" django-picklefield = "^3.1"
blessed = { version = "^1.19.1", optional = true } blessed = { version = "^1.19.1", optional = true }
@@ -55,11 +60,12 @@ django-redis = { version = "^5.2.0", optional = true }
iron-mq = { version = "^0.9", optional = true } iron-mq = { version = "^0.9", optional = true }
boto3 = { version = "^1.24.92", optional = true } boto3 = { version = "^1.24.92", optional = true }
pymongo = { version = "^4.2.0", optional = true } pymongo = { version = "^4.2.0", optional = true }
croniter = { version = "^1.3.7", optional = true } croniter = { version = "^2.0.1", optional = true }
django-q-rollbar = {version = ">=0.1", optional = true} django-q-rollbar = {version = ">=0.1", optional = true}
django-q-sentry = {version = ">=0.1", optional = true} django-q-sentry = {version = ">=0.1", optional = true}
redis = {version = "^4.3.4", optional = true} redis = {version = "^4.3.4", optional = true}
setproctitle = {version = "^1.3.2", optional = true} setproctitle = {version = "^1.3.2", optional = true}
importlib-metadata = {version = ">=3.6", python = "<3.10"}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
@@ -67,8 +73,7 @@ pytest = "^7.1.3"
pytest-django = "^4.5.2" pytest-django = "^4.5.2"
Sphinx = "^4.0.2" Sphinx = "^4.0.2"
pytest-cov = "^4.0.0" pytest-cov = "^4.0.0"
black = "^22.10.0" ruff = "^0.4.4"
isort = {extras = ["requirements_deprecated_finder"], version = "^5.10.1"}
[tool.poetry.extras] [tool.poetry.extras]
requires = ["poetry_core"] requires = ["poetry_core"]

View File

@@ -1,40 +1,41 @@
ansicon==1.89.0; platform_system == "Windows" and python_version >= "2.7" \ asgiref==3.8.1 ; python_version >= "3.8" and python_version < "4" \
--hash=sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec \ --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
--hash=sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1 --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
arrow==1.1.1; python_version >= "3.6" \ backports-zoneinfo==0.2.1 ; python_version >= "3.8" and python_version < "3.9" \
--hash=sha256:77a60a4db5766d900a2085ce9074c5c7b8e2c99afeaa98ad627637ff6f292510 \ --hash=sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf \
--hash=sha256:dee7602f6c60e3ec510095b5e301441bc56288cb8f51def14dcb3079f623823a --hash=sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328 \
asgiref==3.3.4; python_version >= "3.6" \ --hash=sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546 \
--hash=sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee \ --hash=sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6 \
--hash=sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78 --hash=sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570 \
blessed==1.18.1; python_version >= "2.7" \ --hash=sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9 \
--hash=sha256:dd7c0d33db9a2e7f597b446996484d0ed46e1586239db064fb5025008937dcae \ --hash=sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7 \
--hash=sha256:8b09936def6bc06583db99b65636b980075733e13550cb6af262ce724a55da23 --hash=sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987 \
django-picklefield==3.0.1; python_version >= "3" \ --hash=sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722 \
--hash=sha256:15ccba592ca953b9edf9532e64640329cd47b136b7f8f10f2939caa5f9ce4287 \ --hash=sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582 \
--hash=sha256:3c702a54fde2d322fe5b2f39b8f78d9f655b8f77944ab26f703be6c0ed335a35 --hash=sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc \
django==3.2.4; python_version >= "3.6" \ --hash=sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b \
--hash=sha256:ea735cbbbb3b2fba6d4da4784a0043d84c67c92f1fdf15ad6db69900e792c10f \ --hash=sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1 \
--hash=sha256:66c9d8db8cc6fe938a28b7887c1596e42d522e27618562517cc8929eb7e7f296 --hash=sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08 \
jinxed==1.1.0; platform_system == "Windows" and python_version >= "2.7" \ --hash=sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac \
--hash=sha256:6a61ccf963c16aa885304f27e6e5693783676897cea0c7f223270c8b8e78baf8 \ --hash=sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2
--hash=sha256:d8f1731f134e9e6b04d95095845ae6c10eb15cb223a5f0cabdea87d4a279c305 django-picklefield==3.2 ; python_version >= "3.8" and python_version < "4" \
python-dateutil==2.8.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" \ --hash=sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d \
--hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \ --hash=sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c
--hash=sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a django==4.2.11 ; python_version >= "3.8" and python_version < "4" \
pytz==2021.1; python_version >= "3.6" \ --hash=sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4 \
--hash=sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798 \ --hash=sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3
--hash=sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da importlib-metadata==7.1.0 ; python_version >= "3.8" and python_version < "3.10" \
six==1.16.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6" \ --hash=sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570 \
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 \ --hash=sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 sqlparse==0.5.0 ; python_version >= "3.8" and python_version < "4" \
sqlparse==0.4.1; python_version >= "3.6" \ --hash=sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93 \
--hash=sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0 \ --hash=sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663
--hash=sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8 typing-extensions==4.11.0 ; python_version >= "3.8" and python_version < "3.11" \
typing-extensions==3.10.0.0; python_version < "3.8" and python_version >= "3.6" \ --hash=sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0 \
--hash=sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497 \ --hash=sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a
--hash=sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84 \ tzdata==2024.1 ; python_version >= "3.8" and python_version < "4" and sys_platform == "win32" \
--hash=sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342 --hash=sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd \
wcwidth==0.2.5; python_version >= "2.7" \ --hash=sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252
--hash=sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784 \ zipp==3.18.1 ; python_version >= "3.8" and python_version < "3.10" \
--hash=sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83 --hash=sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b \
--hash=sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715

1
ruff.toml Normal file
View File

@@ -0,0 +1 @@
lint.extend-select = ["I"]

View File

@@ -1,5 +1,3 @@
version: '3'
services: services:
redis: redis:
image: redis:latest image: redis:latest

View File

@@ -1,11 +1,16 @@
version: '3'
services: services:
web: web:
restart: always restart: always
command: python manage.py runserver 0.0.0.0:8000 command: bash -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:8000:8000"
build: . build: .
volumes: volumes:
- .:/app - .:/app
django-q:
restart: always
command: bash -c "python manage.py migrate && python manage.py qcluster"
build: .
volumes:
- .:/app