Compare commits

...
93 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 BlanchardandStan Triepels 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 PashleyandScott 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 HooverandDavid 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
GDay 322a53f06d Release v1.5.1 2023-04-02 17:53:23 +02:00
GDay 77821b1de7 Fix release workflow after name change 2023-04-02 17:51:17 +02:00
GDay 38d48231d6 Release v1.5.0 2023-04-02 17:43:17 +02:00
Stan Triepels d13e9d3d64 Update dependencies (2023-04-02) (#88) 2023-04-02 17:15:39 +02:00
sinowoodandStan Triepels 52c04217e9 Multiple queue, multiple cluster in one site (#71)
* Support for multi-queue, multi-cluster configuration. API changes include:
  * Adding `cluster` to async_task() parameters and Task model
  * Adding argument --name to qcluster command
  * Necessary adjustments to Conf and Broker classes
  * Some admin improvements

* Add settings.Q_CLUSTER['ALT_CLUSTERS']: q_cluster config overrides for alternative clusters;
Add Conf.CLUSTER_NAME: separate usage from Conf.PREFIX;
QueueAdmin/OrmQ detail page enhanced: now displaying args/kwargs/q_options instead of encrypted payload.

* if `cluster` argument is not set (the default), async_task() and schedule() will be handled by the default cluster; Documentation update.

* Text cleanup

* Documentation update.

* Fix TIMEOUT setting in Windows for non-default cluster

---------

Co-authored-by: Stan Triepels <1939656+GDay@users.noreply.github.com>
2023-04-02 16:36:17 +02:00
Stan Triepels f8520c9cda Remove version locking on poetry_core to fix regex error (#87) 2023-03-31 02:43:48 +02:00
GDay 86467dcf03 Revert "Merge pull request #15 from django-q2/error-on-timeout"
This reverts commit d97dca5bcb, reversing
changes made to 01cb652071.
2023-03-31 02:22:38 +02:00
Stan Triepels 69a40344f0 Allow building docs all formats (#77) 2023-02-23 01:20:19 +01:00
Jim Jagielski d97dca5bcb Merge pull request #15 from django-q2/error-on-timeout
Error on timeout
2023-02-17 12:41:27 -05:00
Jim Jagielski f5ecd337ad Merge branch 'master' into error-on-timeout 2023-02-17 12:33:47 -05:00
GDay 01cb652071 Release v1.4.11 2023-01-31 00:24:44 +01:00
Stan Triepels b9bdd64568 Remove custom build (revert to auto create setup file) (#70) 2023-01-31 00:21:37 +01:00
Stan Triepels b2bcffbcab Fix missing setup file for "No matching distribution" error (#69) 2023-01-31 00:09:33 +01:00
GDay 25cd180410 Release v1.4.10 2023-01-27 02:10:20 +01:00
Stan Triepels f9e01699c5 Adding translation mo files automatically on build (#65) 2023-01-26 16:45:44 +01:00
Stan Triepels 2f3b0d0a31 Update all dependencies (#64) 2023-01-26 02:56:20 +01:00
Stan Triepels 264607f59c Bump translations to latest changes (#63) 2023-01-26 02:49:46 +01:00
msabatierandStan Triepels 17c1609f10 Add meaningfull process titles with currently running task name (#57)
* Customize process names (in ps/top) and add task name in logs

* Increase severity of reincarnate log messages and add task name

* Document setproctitle optional dependency

Co-authored-by: Marc Sabatier <37879561+msabatier@users.noreply.github.com>
Co-authored-by: Stan Triepels <1939656+GDay@users.noreply.github.com>
2023-01-26 02:21:56 +01:00
msabatierandMarc Sabatier 650c3b1524 Fix use of database router for write queries and remove Conf.HAS_REPLICA (#61)
Co-authored-by: Marc Sabatier
2023-01-18 02:12:13 +01:00
msabatierandMarc Sabatier a013591de5 Add intended_date_kwarg field to Schedule (#62)
Co-authored-by: Marc Sabatier <marc.sabatier@eco-adapt.com>
2023-01-17 22:52:11 +01:00
msabatier 865b1a5ba8 Change task timeout logic to have now() as execution time (#58) 2023-01-11 01:48:57 +01:00
msabatier 8cd1028391 More explicit log messages in exception handling (#59) 2023-01-11 00:36:35 +01:00
GDay 31e82ad028 Release v1.4.9 2022-12-22 03:06:01 +01:00
Stan Triepels b66dfbd3af Fix DST timezone change (move from DST to normal jump) (#56) 2022-12-22 03:02:53 +01:00
GDay 08baaab125 Release v1.4.8 2022-12-21 03:46:01 +01:00
Stan Triepels ce81059da8 Fix: allow both ZoneInfo and Pytz depending on django version (#55) 2022-12-21 03:42:38 +01:00
Stan Triepels cf33275d92 Release v1.4.7 (#54) 2022-12-21 01:53:10 +01:00
Stan Triepels e78e473be3 Fix: handling exceptions inside job function (#51) 2022-12-21 01:44:59 +01:00
Stan Triepels 4d454e4001 Fix: Daylight saving time issue with scheduler (#47) 2022-12-21 01:44:34 +01:00
Stan Triepels cf33891291 Chore: Remove release drafter (#53) 2022-12-21 01:44:10 +01:00
Stan Triepels 134a54dbeb Chore: Fix badge and add download badge (#52) 2022-12-21 01:43:51 +01:00
Stan Triepels c1d4858f62 Release 1.4.6 (#43) 2022-11-30 15:09:49 +01:00
Andreas AndersenandAndreas Bok Andersen 2566955782 Log exceptions with logger.exception (#42)
Co-authored-by: Andreas Bok Andersen <andreas.bokandersen@glooko.com>
2022-11-30 15:03:20 +01:00
Stan Triepels 3c0c32758d Chore: Flake8, isort and Black (#40) 2022-11-16 01:45:40 +01:00
GDay b73fea49b5 Release v1.4.5 2022-11-13 01:12:52 +01:00
GDay cf458304cc [v1.4.4] add sudo to workflow 2022-11-13 01:08:54 +01:00
Stan Triepels 06ad6154e3 Release 1.4.4 (#37) 2022-11-13 01:06:19 +01:00
Stan Triepels 542e04db54 Feat: Add biweekly and bimonthly and fix translations (#36) 2022-11-13 01:00:11 +01:00
Stan Triepels 17205549eb Fix baseconv/base62 deprecation warning for Django 5.x (#34) 2022-11-09 00:41:12 +01:00
Stan Triepels 1764c5c8ae Release 1.4.3 (#32)
Update changelog and bump version number
2022-11-07 17:47:05 +01:00
Mikhail Krassavin f0cb10bc37 Add Python 3.11 support and tests, remove Python 3.7 (#31) 2022-11-07 17:29:29 +01:00
Stan Triepels 167c348509 Update changelog and readme (#29)
Added changes from the previous versions and diff between Q and Q2
2022-11-05 23:06:34 +01:00
Stan Triepels 6f19b3d270 Fix: func reference in admin (#28) 2022-11-05 02:45:44 +01:00
Stan Triepels f62bd7b6b4 Fix install docs: update q to q2 (#25) 2022-10-27 02:08:08 +02:00
Noortheen Raja 64d73a77b7 feat: option to fail-on-timeout
fixes #251
2022-10-19 02:27:12 +02:00
Noortheen Raja 596842e692 chore: pip install support
having build-system config helps installing  this without setup.py built
2022-10-19 02:27:10 +02:00
99 changed files with 4881 additions and 3090 deletions
+4
View File
@@ -0,0 +1,4 @@
# flake8, black, isort
b1d000d007f3f77069719523268a0c6256dc0860
# move to ruff formatting/linting
ad4d24e17c9424b17cd8ae65c2def7ecc74e63c1
-37
View File
@@ -1,37 +0,0 @@
categories:
-
label: breaking
title: Breaking
-
label: feature
title: New
-
label: bug
title: "Bug Fixes"
-
label: dependencies
title: "Dependency Updates"
-
label: security
title: Security
name-template: v$NEXT_PATCH_VERSION
tag-template: v$NEXT_PATCH_VERSION
template: |
# Changes
$CHANGES
version-resolver:
major:
labels:
- breaking
- major
minor:
labels:
- feature
- minor
patch:
labels:
- bug
- dependencies
- security
- patch
default: patch
+9 -7
View File
@@ -7,23 +7,25 @@ on:
jobs: jobs:
build: build:
if: github.repository == 'GDay/django-q2' if: github.repository == 'django-q2/django-q2'
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: |
python -m pip install -U pip setuptools poetry sudo apt-get update
python -m pip install poetry sudo apt-get -y install gettext
python -m pip install pip setuptools django poetry
# compile messages to get .mo files
django-admin compilemessages
- name: Build and publish package - name: Build and publish package
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
run: poetry --build --username=__token__ --password=${{ secrets.PYPI_TOKEN }} publish run: poetry --build --username=__token__ --password=${{ secrets.PYPI_TOKEN }} publish
-14
View File
@@ -1,14 +0,0 @@
name: Update release draft
on:
push:
branches:
- master
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v5
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+32 -6
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" ] 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,7 +94,7 @@ 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: |
+2
View File
@@ -15,6 +15,8 @@ build:
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
formats: all
python: python:
install: install:
- requirements: docs/requirements.txt - requirements: docs/requirements.txt
+195 -2
View File
@@ -1,8 +1,201 @@
# Changelog # Changelog
## [Unreleased](https://github.com/koed00/django-q/tree/HEAD) ## [v1.7.2](https://github.com/django-q2/django-q2/tree/v1.7.2) (2024-09-09)
[Full Changelog](https://github.com/koed00/django-q/compare/v1.3.9...HEAD) - 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)
- Fix release to pipy due to changed org name
## [v1.5.0](https://github.com/GDay/django-q2/tree/v1.5.0) (2023-04-02)
**Merged pull requests:**
- Multiple queue, multiple cluster in one site https://github.com/GDay/django-q2/pull/71
- Allow building docs all formats https://github.com/GDay/django-q2/pull/77
- Remove version locking on `poetry_core` to fix regex error https://github.com/GDay/django-q2/pull/87
- Update dependencies (2023-04-02) https://github.com/GDay/django-q2/pull/88
## [v1.4.11](https://github.com/GDay/django-q2/tree/v1.4.11) (2023-01-30)
**Merged pull requests:**
- Fix missing setup file for "No matching distribution" error https://github.com/GDay/django-q2/pull/69
- Remove custom build (revert to auto create setup file) https://github.com/GDay/django-q2/pull/70
## [v1.4.10](https://github.com/GDay/django-q2/tree/v1.4.10) (2023-01-26)
**Merged pull requests:**
- Adding translation mo files automatically on build https://github.com/GDay/django-q2/pull/65
- Update all dependencies https://github.com/GDay/django-q2/pull/64
- Bump translations to latest changes https://github.com/GDay/django-q2/pull/63
- Add meaningfull process titles with currently running task name https://github.com/GDay/django-q2/pull/57
- Fix use of database router for write queries and remove Conf.HAS_REPLICA https://github.com/GDay/django-q2/pull/61
- Add intended_date_kwarg field to Schedule https://github.com/GDay/django-q2/pull/62
- Change task timeout logic to have now() as execution time https://github.com/GDay/django-q2/pull/58
- More explicit log messages in exception handling https://github.com/GDay/django-q2/pull/59
## [v1.4.9](https://github.com/GDay/django-q2/tree/v1.4.9) (2022-12-22)
**Merged pull requests:**
- Fix DST timezone change (move from DST to normal jump) https://github.com/GDay/django-q2/pull/56
## [v1.4.8](https://github.com/GDay/django-q2/tree/v1.4.8) (2022-12-21)
**Merged pull requests:**
- Fix: allow both ZoneInfo and Pytz depending on django version https://github.com/GDay/django-q2/pull/55
## [v1.4.7](https://github.com/GDay/django-q2/tree/v1.4.7) (2022-12-21)
**Merged pull requests:**
- Fix: handling exceptions inside job function https://github.com/GDay/django-q2/pull/51
- Fix: Daylight saving time issue with scheduler https://github.com/GDay/django-q2/pull/47
- Chore: Fix badge and add download badge https://github.com/GDay/django-q2/pull/52
- Chore: Remove release drafter https://github.com/GDay/django-q2/pull/53
## [v1.4.6](https://github.com/GDay/django-q2/tree/v1.4.6) (2022-11-30)
**Merged pull requests:**
- Fix: Log exceptions with logger.exception https://github.com/GDay/django-q2/pull/42
- Chore: flake8, isort, black https://github.com/GDay/django-q2/pull/40
## [v1.4.5](https://github.com/GDay/django-q2/tree/v1.4.5) (2022-11-13)
- Fix release workflow
## [v1.4.4](https://github.com/GDay/django-q2/tree/v1.4.4) (2022-11-13)
**Merged pull requests:**
- Fix: Deprecation warning for Django 5.x https://github.com/GDay/django-q2/pull/34
- Feat: Add biweekly and bimonthly https://github.com/GDay/django-q2/pull/36
- Fix: Fix all translation strings and remove compiled https://github.com/GDay/django-q2/pull/36
## [v1.4.3](https://github.com/GDay/django-q2/tree/v1.4.3) (2022-11-07)
**Merged pull requests:**
- Fix: func reference in admin https://github.com/GDay/django-q2/pull/28
- Add python 3.11 support and remove 3.7 (as it was never supported by this package anyway)
## [v1.4.2](https://github.com/GDay/django-q2/tree/v1.4.2) (2022-10-22)
**Merged pull requests:**
- Make redis dependency optional and update boto3 #22
## [v1.4.1](https://github.com/GDay/django-q2/tree/v1.4.1) (2022-10-21)
**Merged pull requests:**
- Fix typo configure.rst https://github.com/GDay/django-q2/pull/1
- Update dependencies https://github.com/GDay/django-q2/pull/2
- Show function name in log when running task https://github.com/GDay/django-q2/pull/3
- Codecov -> Coveralls https://github.com/GDay/django-q2/pull/4
- Fix: readthedocs config file https://github.com/GDay/django-q2/pull/5
- Docs updates https://github.com/GDay/django-q2/pull/6
- Feat: Release plan to pypi https://github.com/GDay/django-q2/pull/7
- Feat: Turkish translations https://github.com/GDay/django-q2/pull/8
- Fix: Connection issues with CONN_MAX_AGE > 0 https://github.com/GDay/django-q2/pull/9
- Replace use of eval() by ast.parse() + ast.literal_eval() https://github.com/GDay/django-q2/pull/10
- Admin improvements https://github.com/GDay/django-q2/pull/11
- Save limit per group/func/name https://github.com/GDay/django-q2/pull/12
- Use logger.hasHandlers() to setup fallback logging https://github.com/GDay/django-q2/pull/13
- allow atomic on external db https://github.com/GDay/django-q2/pull/14
- Fix install command and remove old warnings https://github.com/GDay/django-q2/pull/16
- Remove arrow dependency https://github.com/GDay/django-q2/pull/17
- Remove funding file, add docker/compose for development project and fix https://github.com/GDay/django-q2/pull/18
- Fix unclear error when function is not called correctly https://github.com/GDay/django-q2/pull/19
- Remove blessed dependency https://github.com/GDay/django-q2/pull/20
- Release new version and docs fixes https://github.com/GDay/django-q2/pull/21
## v1.4.0
**Closed issues:** **Closed issues:**
+1 -1
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
+2 -2
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
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
+36 -23
View File
@@ -1,11 +1,9 @@
A multiprocessing distributed task queue for Django A multiprocessing distributed task queue for Django
--------------------------------------------------- ---------------------------------------------------
|image0| |image1| |docs| |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
~~~~~~~~ ~~~~~~~~
@@ -22,13 +20,25 @@ Features
- Redis, IronMQ, SQS, MongoDB or ORM - Redis, IronMQ, SQS, MongoDB or ORM
- Rollbar and Sentry support - Rollbar and Sentry support
Changes compared to the original Django-Q:
- Dropped support for Disque (hasn't been updated in a long time)
- Dropped Redis, Arrow and Blessed dependencies
- Updated all current dependencies
- Added tests for Django 4.x and 5.x
- Added Turkish language
- Improved admin area
- Fixed a lot of issues
See the `changelog <https://github.com/GDay/django-q2/blob/master/CHANGELOG.md>`__ for all changes.
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.7, 3.8, 3.9, 3.10 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
~~~~~~~ ~~~~~~~
@@ -61,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
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@@ -92,10 +101,7 @@ 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>
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::
@@ -147,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
~~~~~~~~ ~~~~~~~~
@@ -192,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
~~~~~~~ ~~~~~~~
@@ -200,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
~~~~~~ ~~~~~~
@@ -208,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
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@@ -230,7 +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 .. |downloads| image:: https://img.shields.io/pypi/dm/django-q2
:alt: Documentation Status :target: https://img.shields.io/pypi/dm/django-q2
:scale: 100
:target: https://django-q.readthedocs.org/
+9
View File
@@ -0,0 +1,9 @@
import subprocess
def generate_mo_files():
subprocess.run(["django-admin", "compilemessages"])
if __name__ == "__main__":
generate_mo_files()
+2 -2
View File
@@ -1,7 +1,7 @@
VERSION = (1, 3, 9)
import django import django
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"
+70 -22
View File
@@ -1,19 +1,47 @@
"""Admin module for Django.""" """Admin module for Django."""
from django.contrib import admin
from django.db.models.expressions import OuterRef, Subquery
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db.models.expressions import OuterRef, Subquery
from django_q.conf import Conf, croniter from django_q.conf import Conf, croniter
from django_q.models import Failure, OrmQ, Schedule, Success, Task 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", "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."""
@@ -26,35 +54,33 @@ class TaskAdmin(admin.ModelAdmin):
search_fields = ("name", "func", "group") search_fields = ("name", "func", "group")
readonly_fields = [] readonly_fields = []
list_filter = ("group",) list_filter = ("group", "cluster")
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
"""Set all fields readonly.""" """Set all fields readonly."""
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, **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", "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",) list_filter = ("group", "cluster")
readonly_fields = [] readonly_fields = []
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
@@ -82,18 +108,27 @@ class ScheduleAdmin(admin.ModelAdmin):
readonly_fields = ("cron",) readonly_fields = ("cron",)
list_filter = ("next_run", "schedule_type", "cluster") list_filter = ("next_run", "schedule_type", "cluster")
search_fields = ("name", "func",) search_fields = (
"name",
"func",
)
list_display_links = ("id", "name") list_display_links = ("id", "name")
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
task_query = Task.objects.filter(id=OuterRef('task')).values('id', 'name', 'success') task_query = Task.objects.filter(id=OuterRef("task")).values(
qs = qs.annotate(task_id=Subquery(task_query.values('id')), task_name=Subquery(task_query.values('name')), "id", "name", "success"
task_success=Subquery(task_query.values('success'))) )
qs = qs.annotate(
task_id=Subquery(task_query.values("id")),
task_name=Subquery(task_query.values("name")),
task_success=Subquery(task_query.values("success")),
)
return qs return qs
def get_success(self, obj): def get_success(self, obj):
return obj.task_success return obj.task_success
get_success.boolean = True get_success.boolean = True
get_success.short_description = _("success") get_success.short_description = _("success")
@@ -105,6 +140,7 @@ class ScheduleAdmin(admin.ModelAdmin):
url = reverse("admin:django_q_failure_change", args=(obj.task_id,)) url = reverse("admin:django_q_failure_change", args=(obj.task_id,))
return format_html(f'<a href="{url}">[{obj.task_name}]</a>') return format_html(f'<a href="{url}">[{obj.task_name}]</a>')
return None return None
get_last_run.allow_tags = True get_last_run.allow_tags = True
get_last_run.short_description = _("last_run") get_last_run.short_description = _("last_run")
@@ -112,7 +148,19 @@ class ScheduleAdmin(admin.ModelAdmin):
class QueueAdmin(admin.ModelAdmin): 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",
)
readonly_fields = fields[2:]
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
obj.save(using=Conf.ORM) obj.save(using=Conf.ORM)
+1 -1
View File
@@ -9,4 +9,4 @@ class DjangoQConfig(AppConfig):
default_auto_field = "django.db.models.AutoField" default_auto_field = "django.db.models.AutoField"
def ready(self): def ready(self):
from django_q.signals import call_hook from django_q.signals import call_hook # noqa: F401
+7 -4
View File
@@ -7,7 +7,9 @@ from django_q.conf import Conf
class Broker: class Broker:
def __init__(self, list_key: str = Conf.PREFIX): def __init__(self, list_key: str = None):
# With same BROKER_CLASS, `list_key` is just a synonym for `queue_name` except for RedisBroker
list_key = list_key or Conf.CLUSTER_NAME
self.connection = self.get_connection(list_key) self.connection = self.get_connection(list_key)
self.list_key = list_key self.list_key = list_key
self.cache = self.get_cache() self.cache = self.get_cache()
@@ -151,7 +153,7 @@ class Broker:
return None return None
@staticmethod @staticmethod
def get_connection(list_key: str = Conf.PREFIX): def get_connection(list_key: str = None):
""" """
Gets a connection to the broker Gets a connection to the broker
:param list_key: Optional queue name :param list_key: Optional queue name
@@ -160,13 +162,14 @@ class Broker:
return 0 return 0
def get_broker(list_key: str = Conf.PREFIX) -> Broker: def get_broker(list_key: str = None) -> Broker:
""" """
Gets the configured broker type Gets the configured broker type
:param list_key: optional queue name :param list_key: optional queue name
:type list_key: str :type list_key: str
:return: a broker instance :return: a broker instance
""" """
list_key = list_key or Conf.CLUSTER_NAME
# custom # custom
if Conf.BROKER_CLASS: if Conf.BROKER_CLASS:
module, func = Conf.BROKER_CLASS.rsplit(".", 1) module, func = Conf.BROKER_CLASS.rsplit(".", 1)
@@ -179,7 +182,7 @@ def get_broker(list_key: str = Conf.PREFIX) -> 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)
+4 -3
View File
@@ -8,7 +8,7 @@ QUEUE_DOES_NOT_EXIST = "AWS.SimpleQueueService.NonExistentQueue"
class Sqs(Broker): class Sqs(Broker):
def __init__(self, list_key: str = Conf.PREFIX): def __init__(self, list_key: str = None):
self.sqs = None self.sqs = None
super(Sqs, self).__init__(list_key) super(Sqs, self).__init__(list_key)
self.queue = self.get_queue() self.queue = self.get_queue()
@@ -39,7 +39,8 @@ class Sqs(Broker):
raise ValueError("receive_message_wait_time_seconds should be int") raise ValueError("receive_message_wait_time_seconds should be int")
if wait_time_second > 20: if wait_time_second > 20:
raise ValueError( raise ValueError(
"receive_message_wait_time_seconds is invalid. Reason: Must be >= 0 and <= 20" "receive_message_wait_time_seconds is invalid. Reason: Must be >= 0"
" and <= 20"
) )
params.update({"WaitTimeSeconds": wait_time_second}) params.update({"WaitTimeSeconds": wait_time_second})
@@ -76,7 +77,7 @@ class Sqs(Broker):
return "AWS SQS" return "AWS SQS"
@staticmethod @staticmethod
def get_connection(list_key: str = Conf.PREFIX) -> Session: def get_connection(list_key: str = None) -> Session:
config = Conf.SQS config = Conf.SQS
if "aws_region" in config: if "aws_region" in config:
config["region_name"] = config["aws_region"] config["region_name"] = config["aws_region"]
+2 -1
View File
@@ -46,6 +46,7 @@ class IronMQBroker(Broker):
return self.delete(task_id) return self.delete(task_id)
@staticmethod @staticmethod
def get_connection(list_key: str = Conf.PREFIX) -> Queue: def get_connection(list_key: str = None) -> Queue:
list_key = list_key or Conf.CLUSTER_NAME
ironmq = IronMQ(name=None, **Conf.IRON_MQ) ironmq = IronMQ(name=None, **Conf.IRON_MQ)
return ironmq.queue(queue_name=list_key) return ironmq.queue(queue_name=list_key)
+2 -2
View File
@@ -15,7 +15,7 @@ def _timeout():
class Mongo(Broker): class Mongo(Broker):
def __init__(self, list_key=Conf.PREFIX): def __init__(self, list_key: str = None):
super(Mongo, self).__init__(list_key) super(Mongo, self).__init__(list_key)
self.collection = self.get_collection() self.collection = self.get_collection()
@@ -24,7 +24,7 @@ class Mongo(Broker):
self.collection = self.get_collection() self.collection = self.get_collection()
@staticmethod @staticmethod
def get_connection(list_key: str = Conf.PREFIX) -> MongoClient: def get_connection(list_key: str = None) -> MongoClient:
return MongoClient(**Conf.MONGO) return MongoClient(**Conf.MONGO)
def get_collection(self): def get_collection(self):
+15 -9
View File
@@ -11,12 +11,12 @@ from django_q.models import OrmQ
def _timeout(): def _timeout():
return timezone.now() - timedelta(seconds=Conf.RETRY) return timezone.now() + timedelta(seconds=Conf.RETRY)
class ORM(Broker): class ORM(Broker):
@staticmethod @staticmethod
def get_connection(list_key: str = Conf.PREFIX): def get_connection(list_key: str = None):
if transaction.get_autocommit( if transaction.get_autocommit(
using=Conf.ORM using=Conf.ORM
): # Only True when not in an atomic block ): # Only True when not in an atomic block
@@ -31,13 +31,15 @@ class ORM(Broker):
def queue_size(self) -> int: def queue_size(self) -> int:
return ( return (
self.get_connection() self.get_connection()
.filter(key=self.list_key, lock__lte=_timeout()) .filter(key=self.list_key, lock__lte=timezone.now())
.count() .count()
) )
def lock_size(self) -> int: def lock_size(self) -> int:
return ( return (
self.get_connection().filter(key=self.list_key, lock__gt=_timeout()).count() self.get_connection()
.filter(key=self.list_key, lock__gt=timezone.now())
.count()
) )
def purge_queue(self): def purge_queue(self):
@@ -55,14 +57,17 @@ class ORM(Broker):
self.delete(task_id) self.delete(task_id)
def enqueue(self, task): def enqueue(self, task):
# list_key might be null (e.g. in a test setup) but OrmQ.key has not-null constraint
package = self.get_connection().create( package = self.get_connection().create(
key=self.list_key, payload=task, lock=_timeout() key=self.list_key or Conf.CLUSTER_NAME, payload=task, lock=timezone.now()
) )
return package.pk return package.pk
def dequeue(self): def dequeue(self):
tasks = self.get_connection().filter(key=self.list_key, lock__lt=_timeout())[ tasks = self.get_connection().filter(
0 : Conf.BULK key=self.list_key, lock__lt=timezone.now()
)[
0 : Conf.BULK # noqa: E203
] ]
if tasks: if tasks:
task_list = [] task_list = []
@@ -70,10 +75,11 @@ class ORM(Broker):
if ( if (
self.get_connection() self.get_connection()
.filter(id=task.id, lock=task.lock) .filter(id=task.id, lock=task.lock)
.update(lock=timezone.now()) .update(lock=_timeout())
): ):
task_list.append((task.pk, task.payload)) task_list.append((task.pk, task.payload))
# else don't process, as another cluster has been faster than us on that task # else don't process, as another cluster has been faster than us on
# that task
return task_list return task_list
# empty queue, spare the cpu # empty queue, spare the cpu
sleep(Conf.POLL) sleep(Conf.POLL)
+3 -2
View File
@@ -11,7 +11,8 @@ except ImportError:
class Redis(Broker): class Redis(Broker):
def __init__(self, list_key: str = Conf.PREFIX): def __init__(self, list_key: str = None):
list_key = list_key or Conf.CLUSTER_NAME
super(Redis, self).__init__(list_key=f"django_q:{list_key}:q") super(Redis, self).__init__(list_key=f"django_q:{list_key}:q")
def enqueue(self, task): def enqueue(self, task):
@@ -57,7 +58,7 @@ class Redis(Broker):
return self.connection.mget(keys) return self.connection.mget(keys)
@staticmethod @staticmethod
def get_connection(list_key: str = Conf.PREFIX) -> Redis: def get_connection(list_key: str = None) -> Redis:
if django_redis and Conf.DJANGO_REDIS: if django_redis and Conf.DJANGO_REDIS:
return django_redis.get_redis_connection(Conf.DJANGO_REDIS) return django_redis.get_redis_connection(Conf.DJANGO_REDIS)
if isinstance(Conf.REDIS, str): if isinstance(Conf.REDIS, str):
+87 -440
View File
@@ -1,12 +1,7 @@
# Standard # Standard
import ast
import inspect
import pydoc
import signal import signal
import socket import socket
import traceback
import uuid import uuid
from datetime import datetime, timedelta
from multiprocessing import Event, Process, Value, current_process from multiprocessing import Event, Process, Value, current_process
from time import sleep from time import sleep
@@ -21,51 +16,52 @@ except core.exceptions.AppRegistryNotReady:
django.setup() django.setup()
from django.conf import settings
from django.utils import timezone 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,
croniter,
error_reporter,
get_ppid, get_ppid,
logger, logger,
prometheus_multiprocess,
psutil, psutil,
resource, setproctitle,
) )
from django_q.humanhash import humanize from django_q.humanhash import humanize
from django_q.models import Schedule, Success, Task from django_q.monitor import monitor
from django_q.pusher import pusher
from django_q.queues import Queue from django_q.queues import Queue
from django_q.signals import post_execute, pre_execute from django_q.scheduler import scheduler
from django_q.signing import BadSignature, SignedPackage
from django_q.status import Stat, Status from django_q.status import Stat, Status
from django_q.worker import worker
from .utils import add_months, add_years
class Cluster: class Cluster:
def __init__(self, broker: Broker = None): def __init__(self, broker: Broker = None):
self.broker = broker or get_broker() # Cluster do not need an init or default broker except for testing,
# The sentinel will create a broker for cluster and utilize ALT_CLUSTERS config in Conf.
self.broker = broker # DON'T USE get_broker() to set a default broker here.
self.sentinel = None self.sentinel = None
self.stop_event = None self.stop_event = None
self.start_event = None self.start_event = None
self.pid = current_process().pid self.pid = current_process().pid
self.cluster_id = uuid.uuid4() self.cluster_id = uuid.uuid4()
self.host = socket.gethostname() self.host = socket.gethostname()
self.timeout = Conf.TIMEOUT self.timeout = None
signal.signal(signal.SIGTERM, self.sig_handler) signal.signal(signal.SIGTERM, self.sig_handler)
signal.signal(signal.SIGINT, self.sig_handler) signal.signal(signal.SIGINT, self.sig_handler)
def start(self) -> int: def start(self) -> int:
if setproctitle:
setproctitle.setproctitle(f"qcluster {current_process().name} {self.name}")
# Start Sentinel # Start Sentinel
self.stop_event = Event() self.stop_event = Event()
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,
@@ -75,7 +71,7 @@ class Cluster:
), ),
) )
self.sentinel.start() self.sentinel.start()
logger.info(_(f"Q Cluster {self.name} starting.")) logger.info(_("Q Cluster %(name)s starting.") % {"name": self.name})
while not self.start_event.is_set(): while not self.start_event.is_set():
sleep(0.1) sleep(0.1)
return self.pid return self.pid
@@ -83,19 +79,21 @@ class Cluster:
def stop(self) -> bool: def stop(self) -> bool:
if not self.sentinel.is_alive(): if not self.sentinel.is_alive():
return False return False
logger.info(_(f"Q Cluster {self.name} stopping.")) logger.info(_("Q Cluster %(name)s stopping.") % {"name": self.name})
self.stop_event.set() self.stop_event.set()
self.sentinel.join() self.sentinel.join()
logger.info(_(f"Q Cluster {self.name} has stopped.")) logger.info(_("Q Cluster %(name)s has stopped.") % {"name": self.name})
self.start_event = None self.start_event = None
self.stop_event = None self.stop_event = None
return True return True
def sig_handler(self, signum, frame): def sig_handler(self, signum, frame):
logger.debug( logger.debug(
_( _("%(name)s got signal %(signal)s")
f'{current_process().name} got signal {Conf.SIGNAL_NAMES.get(signum, "UNKNOWN")}' % {
) "name": current_process().name,
"signal": Conf.SIGNAL_NAMES.get(signum, "UNKNOWN"),
}
) )
self.stop() self.stop()
@@ -138,7 +136,7 @@ class Sentinel:
start_event, start_event,
cluster_id, cluster_id,
broker=None, broker=None,
timeout=Conf.TIMEOUT, timeout=None,
start=True, start=True,
): ):
# Make sure we catch signals for the pool # Make sure we catch signals for the pool
@@ -155,7 +153,7 @@ class Sentinel:
self.start_event = start_event self.start_event = start_event
self.pool_size = Conf.WORKERS self.pool_size = Conf.WORKERS
self.pool = [] self.pool = []
self.timeout = timeout self.timeout = timeout or Conf.TIMEOUT
self.task_queue = ( self.task_queue = (
Queue(maxsize=Conf.QUEUE_LIMIT) if Conf.QUEUE_LIMIT else Queue() Queue(maxsize=Conf.QUEUE_LIMIT) if Conf.QUEUE_LIMIT else Queue()
) )
@@ -166,6 +164,10 @@ class Sentinel:
if start: if start:
self.start() self.start()
def queue_name(self):
# multi-queue: cluster name is (broker's) queue_name
return self.broker.list_key if self.broker else "--"
def start(self): def start(self):
self.broker.ping() self.broker.ping()
self.spawn_cluster() self.spawn_cluster()
@@ -187,7 +189,7 @@ class Sentinel:
""" """
:type target: function or class :type target: function or class
""" """
p = Process(target=target, args=args) p = Process(target=target, args=args, name=f"Process-{uuid.uuid4().hex}")
p.daemon = True p.daemon = True
if target == worker: if target == worker:
p.daemon = Conf.DAEMONIZE_WORKERS p.daemon = Conf.DAEMONIZE_WORKERS
@@ -217,21 +219,53 @@ class Sentinel:
db.connections.close_all() db.connections.close_all()
if process == self.monitor: if process == self.monitor:
self.monitor = self.spawn_monitor() self.monitor = self.spawn_monitor()
logger.error(_(f"reincarnated monitor {process.name} after sudden death")) logger.critical(
_("reincarnated monitor %(name)s after sudden death")
% {"name": process.name}
)
elif process == self.pusher: elif process == self.pusher:
self.pusher = self.spawn_pusher() self.pusher = self.spawn_pusher()
logger.error(_(f"reincarnated pusher {process.name} after sudden death")) logger.critical(
_("reincarnated pusher %(name)s after sudden death")
% {"name": process.name}
)
else: else:
if prometheus_multiprocess:
prometheus_multiprocess.mark_process_dead(process.pid)
self.pool.remove(process) self.pool.remove(process)
self.spawn_worker() self.spawn_worker()
if process.timer.value == 0: if process.timer.value == 0:
# only need to terminate on timeout, otherwise we risk destabilizing the queues # 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() process.terminate()
logger.warning(_(f"reincarnated worker {process.name} after timeout")) 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: elif int(process.timer.value) == -2:
logger.info(_(f"recycled worker {process.name}")) logger.info(_("recycled worker %(name)s") % {"name": process.name})
else: else:
logger.error(_(f"reincarnated worker {process.name} after death")) logger.critical(
_("reincarnated worker %(name)s after death")
% {"name": process.name}
)
self.reincarnations += 1 self.reincarnations += 1
@@ -253,13 +287,22 @@ class Sentinel:
def guard(self): def guard(self):
logger.info( logger.info(
_( _("%(name)s guarding cluster %(cluster_name)s")
f"{current_process().name} guarding cluster {humanize(self.cluster_id.hex)}" % {
) "name": current_process().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(_(f"Q Cluster {humanize(self.cluster_id.hex)} running.")) logger.info(
_("Q Cluster %(cluster_name)s running.")
% {
"cluster_name": humanize(self.cluster_id.hex)
+ f" [{self.queue_name()}]"
}
)
counter = 0 counter = 0
cycle = Conf.GUARD_CYCLE # guard loop sleep in seconds cycle = Conf.GUARD_CYCLE # guard loop sleep in seconds
# Guard loop. Runs at least once # Guard loop. Runs at least once
@@ -293,7 +336,7 @@ class Sentinel:
def stop(self): def stop(self):
Stat(self).save() Stat(self).save()
name = current_process().name name = current_process().name
logger.info(_(f"{name} stopping cluster processes")) logger.info(_("%(name)s stopping cluster processes") % {"name": name})
# Stopping pusher # Stopping pusher
self.event_out.set() self.event_out.set()
# Wait for it to stop # Wait for it to stop
@@ -318,7 +361,7 @@ class Sentinel:
self.result_queue.close() self.result_queue.close()
# Wait for the result queue to empty # Wait for the result queue to empty
self.result_queue.join_thread() self.result_queue.join_thread()
logger.info(_(f"{name} waiting for the monitor.")) logger.info(_("%(name)s waiting for the monitor.") % {"name": name})
# Wait for everything to close or time out # Wait for everything to close or time out
count = 0 count = 0
if not self.timeout: if not self.timeout:
@@ -331,389 +374,6 @@ class Sentinel:
Stat(self).save() Stat(self).save()
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()
logger.info(_(f"{current_process().name} pushing tasks at {current_process().pid}"))
while True:
try:
task_set = broker.dequeue()
except Exception as e:
logger.error(e, traceback.format_exc())
# 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) as e:
logger.error(e, traceback.format_exc())
broker.fail(ack_id)
continue
task["ack_id"] = ack_id
task_queue.put(task)
logger.debug(_(f"queueing from {broker.list_key}"))
if event.is_set():
break
logger.info(_(f"{current_process().name} stopped pushing tasks"))
def monitor(result_queue: Queue, broker: Broker = None):
"""
Gets finished tasks from the result queue and saves them to Django
:type broker: brokers.Broker
:type result_queue: multiprocessing.Queue
"""
if not broker:
broker = get_broker()
name = current_process().name
logger.info(_(f"{name} monitoring at {current_process().pid}"))
for task in iter(result_queue.get, "STOP"):
# save the result
if task.get("cached", False):
save_cached(task, broker)
else:
save_task(task, broker)
# acknowledge result
ack_id = task.pop("ack_id", False)
if ack_id and (task["success"] or task.get("ack_failure", False)):
broker.acknowledge(ack_id)
# signal execution done
post_execute.send(sender="django_q", task=task)
# log the result
info_name = get_func_repr(task['func'])
if task["success"]:
# log success
logger.info(_(f"Processed '{info_name}' ({task['name']})"))
else:
# log failure
logger.error(_(f"Failed '{info_name}' ({task['name']}) - {task['result']}"))
logger.info(_(f"{name} stopped monitoring results"))
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(_(f"{proc_name} ready for work at {current_process().pid}"))
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
# Get the function from the task
func = task["func"]
func_name = get_func_repr(func)
logger.info(_(f"{proc_name} processing '{func_name}' ({task['name']})"))
f = task["func"]
# if it's not an instance try to get it from the string
if not callable(task["func"]):
f = pydoc.locate(f)
close_old_django_connections()
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
try:
res = f(*task["args"], **task["kwargs"])
result = (res, True)
except Exception:
result = (f"Could not process '{func_name}'. Check the location of the function and the args/kwargs.", False)
if error_reporter:
error_reporter.report()
if task.get("sync", False):
raise Exception(result)
with timer.get_lock():
# Process result
task["result"] = result[0]
task["success"] = result[1]
task["stopped"] = timezone.now()
result_queue.put(task)
timer.value = -1 # Idle
# Recycle
if task_count == Conf.RECYCLE or rss_check():
timer.value = -2 # Recycled
break
logger.info(_(f"{proc_name} stopped doing work"))
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)
def save_task(task, 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 task.get("save", Conf.SAVE_LIMIT >= 0) and task["success"]:
return
# enqueues next in a chain
if task.get("chain", None):
django_q.tasks.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()
try:
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
database_to_use = {"using": Conf.ORM if Conf.ORM else Schedule.objects.db} if not Conf.HAS_REPLICA else {}
with db.transaction.atomic(**database_to_use):
last = Success.objects.filter(**filters).select_for_update().last()
if task["success"] and 0 < Conf.SAVE_LIMIT <= Success.objects.filter(**filters).count():
last.delete()
# check if this task has previous results
try:
existing_task = Task.objects.get(id=task["id"], name=task["name"])
# only update the result if it hasn't succeeded yet
if not existing_task.success:
existing_task.stopped = task["stopped"]
existing_task.result = task["result"]
existing_task.success = task["success"]
existing_task.attempt_count = existing_task.attempt_count + 1
existing_task.save()
if (
Conf.MAX_ATTEMPTS > 0
and existing_task.attempt_count >= Conf.MAX_ATTEMPTS
):
broker.acknowledge(task["ack_id"])
except Task.DoesNotExist:
# convert func to string
func = get_func_repr(task["func"])
Task.objects.create(
id=task["id"],
name=task["name"],
func=func,
hook=task.get("hook"),
args=task["args"],
kwargs=task["kwargs"],
started=task["started"],
stopped=task["stopped"],
result=task["result"],
group=task.get("group"),
success=task["success"],
attempt_count=1,
)
except Exception as e:
logger.error(e)
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
# save the group list
group_list.append(task_key)
broker.cache.set(group_key, group_list, timeout)
# async_task next in a chain
if task.get("chain", None):
django_q.tasks.async_chain(
task["chain"],
group=group,
cached=task["cached"],
sync=task["sync"],
broker=broker,
)
# save the task
broker.cache.set(task_key, SignedPackage.dumps(task), timeout)
except Exception as e:
logger.error(e)
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:
database_to_use = {"using": Conf.ORM if Conf.ORM else Schedule.objects.db} if not Conf.HAS_REPLICA else {}
with db.transaction.atomic(**database_to_use):
for s in (
Schedule.objects.select_for_update()
.exclude(repeats=0)
.filter(next_run__lt=timezone.now())
.filter(
db.models.Q(cluster__isnull=True) | db.models.Q(cluster=Conf.PREFIX)
)
):
args = ()
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", {})
if s.hook:
q_options["hook"] = s.hook
# set up the next run time
if s.schedule_type != s.ONCE:
next_run = s.next_run
while True:
if s.schedule_type == s.MINUTES:
next_run = next_run + timedelta(minutes=(s.minutes or 1))
elif s.schedule_type == s.HOURLY:
next_run = next_run + timedelta(hours=1)
elif s.schedule_type == s.DAILY:
next_run = next_run + timedelta(days=1)
elif s.schedule_type == s.WEEKLY:
next_run = next_run + timedelta(weeks=1)
elif s.schedule_type == s.MONTHLY:
next_run = add_months(next_run, 1)
elif s.schedule_type == s.QUARTERLY:
next_run = add_months(next_run, 3)
elif s.schedule_type == s.YEARLY:
next_run = add_years(next_run, 1)
elif s.schedule_type == s.CRON:
if not croniter:
raise ImportError(
_(
"Please install croniter to enable cron expressions"
)
)
next_run = croniter(s.cron, localtime()).get_next(datetime)
if Conf.CATCH_UP or next_run > localtime():
break
s.next_run = next_run
s.repeats += -1
# send it to the cluster
scheduled_broker = broker
try:
scheduled_broker = get_broker(q_options["broker_name"])
except: # invalid broker_name or non existing broker with broker_name
pass
q_options["broker"] = scheduled_broker
q_options["group"] = q_options.get("group", s.name or s.id)
kwargs["q_options"] = q_options
s.task = django_q.tasks.async_task(s.func, *args, **kwargs)
# log it
if not s.task:
logger.error(
_(
f"{current_process().name} failed to create a task from schedule [{s.name or s.id}]"
)
)
else:
logger.info(
_(
f"{current_process().name} created a task from schedule [{s.name or s.id}]"
)
)
# default behavior is to delete a ONCE schedule
if s.schedule_type == s.ONCE:
if s.repeats < 0:
s.delete()
continue
# but not if it has a positive repeats
s.repeats = 0
# save the schedule
s.save()
except Exception as e:
logger.error(e)
def close_old_django_connections():
"""
Close django connections unless running with sync=True.
"""
if Conf.SYNC:
logger.warning(
"Preserving django database connections because sync=True. Beware "
"that tasks are now injected in the calling context/transactions "
"which may result in unexpected behaviour."
)
else:
db.close_old_connections()
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):
""" """
Sets the cpu affinity for the supplied processes. Sets the cpu affinity for the supplied processes.
@@ -724,12 +384,12 @@ def set_cpu_affinity(n: int, process_ids: list, actual: bool = not Conf.TESTING)
""" """
# check if we have the psutil module # check if we have the psutil module
if not psutil: if not psutil:
logger.warning("Skipping cpu affinity because psutil was not found.") logger.warning(_("Skipping cpu affinity because psutil was not found."))
return return
# check if the platform supports cpu_affinity # check if the platform supports cpu_affinity
if actual and not hasattr(psutil.Process(process_ids[0]), "cpu_affinity"): if actual and not hasattr(psutil.Process(process_ids[0]), "cpu_affinity"):
logger.warning( logger.warning(
"Faking cpu affinity because it is not supported on this platform" _("Faking cpu affinity because it is not supported on this platform")
) )
actual = False actual = False
# get the available processors # get the available processors
@@ -750,20 +410,7 @@ def set_cpu_affinity(n: int, process_ids: list, actual: bool = not Conf.TESTING)
p = psutil.Process(pid) p = psutil.Process(pid)
if actual: if actual:
p.cpu_affinity(affinity) p.cpu_affinity(affinity)
logger.info(_(f"{pid} will use cpu {affinity}")) logger.info(
_("%(pid)s will use cpu %(affinity)s")
% {"pid": pid, "affinity": affinity}
def rss_check(): )
if Conf.MAX_RSS:
if resource:
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss >= Conf.MAX_RSS
elif psutil:
return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024
return False
def localtime() -> datetime:
"""Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ:
return timezone.localtime()
return datetime.now()
+68 -20
View File
@@ -1,16 +1,22 @@
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 django_q.queues 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:
import psutil import psutil
@@ -27,6 +33,16 @@ try:
except ModuleNotFoundError: except ModuleNotFoundError:
resource = None resource = None
try:
import setproctitle
except ModuleNotFoundError:
setproctitle = None
try:
from prometheus_client import multiprocess as prometheus_multiprocess
except ModuleNotFoundError:
prometheus_multiprocess = None
class Conf: class Conf:
""" """
@@ -34,10 +50,25 @@ class Conf:
""" """
try: try:
conf = settings.Q_CLUSTER conf = settings.Q_CLUSTER.copy()
except AttributeError: except AttributeError:
conf = {} conf = {}
_Q_CLUSTER_NAME = os.getenv("Q_CLUSTER_NAME")
if (
_Q_CLUSTER_NAME
and _Q_CLUSTER_NAME != conf.get("name")
and _Q_CLUSTER_NAME != conf.get("cluster_name")
):
conf["cluster_name"] = _Q_CLUSTER_NAME
alt_conf = conf.pop("ALT_CLUSTERS")
if isinstance(alt_conf, dict):
alt_conf = alt_conf.get(_Q_CLUSTER_NAME)
if isinstance(alt_conf, dict):
alt_conf.pop("name", None)
alt_conf.pop("cluster_name", None)
conf.update(alt_conf)
# Redis server configuration . Follows standard redis keywords # Redis server configuration . Follows standard redis keywords
REDIS = conf.get("redis", {}) REDIS = conf.get("redis", {})
@@ -54,9 +85,6 @@ class Conf:
# ORM broker # ORM broker
ORM = conf.get("orm", None) ORM = conf.get("orm", None)
# ORM support for read/write replicas
HAS_REPLICA = conf.get("has_replica", False)
# Custom broker class # Custom broker class
BROKER_CLASS = conf.get("broker_class", None) BROKER_CLASS = conf.get("broker_class", None)
@@ -68,12 +96,19 @@ class Conf:
MONGO_DB = conf.get("mongo_db", None) MONGO_DB = conf.get("mongo_db", None)
# Name of the cluster or site. For when you run multiple sites on one redis server # Name of the cluster or site. For when you run multiple sites on one redis server
# It's also the `salt` for signing OrmQ, and part of the Redis stats caching key
# For all clusters in one site, PREFIX should be the same value to be able to decrypt payloads
PREFIX = conf.get("name", "default") PREFIX = conf.get("name", "default")
# Support alternative cluster name to use multiple queues in one site.
# cluster name and queue name are interchangeable, same thing.
CLUSTER_NAME = conf.get("cluster_name", PREFIX)
# Log output level # Log output level
LOG_LEVEL = conf.get("log_level", "INFO") LOG_LEVEL = conf.get("log_level", "INFO")
# Maximum number of successful tasks kept in the database. 0 saves everything. -1 saves none # Maximum number of successful tasks kept in the database. 0 saves everything.
# -1 saves none
# Failures are always saved # Failures are always saved
SAVE_LIMIT = conf.get("save_limit", 250) SAVE_LIMIT = conf.get("save_limit", 250)
@@ -82,7 +117,13 @@ class Conf:
# Verify SAVE_LIMIT_PER is valid # Verify SAVE_LIMIT_PER is valid
if SAVE_LIMIT_PER not in ["group", "name", "func", None]: if SAVE_LIMIT_PER not in ["group", "name", "func", None]:
warn(f"SAVE_LIMIT_PER ({SAVE_LIMIT_PER}) is not a valid option. Options are: 'group', 'name', 'func' and None. Default is None.") warn(
_(
"SAVE_LIMIT_PER (%(option)s) is not a valid option. Options are: "
"'group', 'name', 'func' and None. Default is None."
)
% {"option": SAVE_LIMIT_PER}
)
# 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", 0.5) GUARD_CYCLE = conf.get("guard_cycle", 0.5)
@@ -113,11 +154,12 @@ class Conf:
# Sets compression of redis packages # Sets compression of redis packages
COMPRESSED = conf.get("compress", False) COMPRESSED = conf.get("compress", False)
# Number of tasks each worker can handle before it gets recycled. Useful for releasing memory # Number of tasks each worker can handle before it gets recycled.
# Useful for releasing memory
RECYCLE = conf.get("recycle", 500) RECYCLE = conf.get("recycle", 500)
# The maximum resident set size in kilobytes before a worker will recycle. Useful for limiting memory usage # The maximum resident set size in kilobytes before a worker will recycle.
# Not available on all platforms # Useful for limiting memory usage. Not available on all platforms
MAX_RSS = conf.get("max_rss", None) MAX_RSS = conf.get("max_rss", None)
# Number of seconds to wait for a worker to finish. # Number of seconds to wait for a worker to finish.
@@ -135,9 +177,10 @@ class Conf:
# Verify if retry and timeout settings are correct # Verify if retry and timeout settings are correct
if not TIMEOUT or (TIMEOUT > RETRY): if not TIMEOUT or (TIMEOUT > RETRY):
warn( warn(
"""Retry and timeout are misconfigured. Set retry larger than timeout, "Retry and timeout are misconfigured. Set retry larger than timeout,"
failure to do so will cause the tasks to be retriggered before completion. "failure to do so will cause the tasks to be retriggered before completion."
See https://django-q2.readthedocs.io/en/master/configure.html#retry for details.""" "See https://django-q2.readthedocs.io/en/master/configure.html#retry "
"for details."
) )
# Sets the amount of tasks the cluster will try to pop off the broker. # Sets the amount of tasks the cluster will try to pop off the broker.
@@ -156,12 +199,14 @@ class Conf:
# The Django cache to use # The Django cache to use
CACHE = conf.get("cache", "default") CACHE = conf.get("cache", "default")
# Use the cache as result backend. Can be 'True' or an integer representing the global cache timeout. # Use the cache as result backend. Can be 'True' or an integer representing the
# global cache timeout.
# i.e 'cached: 60' , will make all results go the cache and expire in 60 seconds. # i.e 'cached: 60' , will make all results go the cache and expire in 60 seconds.
CACHED = conf.get("cached", False) CACHED = conf.get("cached", False)
# If set to False the scheduler won't execute tasks in the past. # If set to False the scheduler won't execute tasks in the past.
# Instead it will run once and reschedule the next run in the future. Defaults to True. # Instead it will run once and reschedule the next run in the future. Defaults to
# True.
CATCH_UP = conf.get("catch_up", True) CATCH_UP = conf.get("catch_up", True)
# Use the secret key for package signing # Use the secret key for package signing
@@ -200,6 +245,11 @@ class Conf:
# to manage workarounds during testing # to manage workarounds during testing
TESTING = conf.get("testing", False) TESTING = conf.get("testing", False)
# Timezone for next_run, overrules Django timezone
TIME_ZONE = None
if settings.USE_TZ:
TIME_ZONE = conf.get("time_zone", settings.TIME_ZONE)
# logger # logger
logger = logging.getLogger("django-q") logger = logging.getLogger("django-q")
@@ -218,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]
@@ -237,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)
@@ -257,5 +304,6 @@ def get_ppid():
return psutil.Process(os.getpid()).ppid() return psutil.Process(os.getpid()).ppid()
else: else:
raise OSError( raise OSError(
"Your OS does not support `os.getppid`. Please install `psutil` as an alternative provider." "Your OS does not support `os.getppid`. Please install `psutil` as an "
"alternative provider."
) )
+20 -5
View File
@@ -2,11 +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
from django.utils import baseconv try:
from django.core.signing import b62_decode
except ImportError:
# fallback for django 3.x
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
@@ -32,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
@@ -69,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 = baseconv.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()
+7
View File
@@ -0,0 +1,7 @@
class TimeoutException(SystemExit):
"""
Exception for when a worker takes too long to complete a task
Raising SystemExit will make sure the function terminates gracefully.
"""
pass
+10 -7
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.
@@ -337,17 +335,22 @@ class HumanHasher:
# Split `bytes` into `target` segments. # Split `bytes` into `target` segments.
seg_size = length // target seg_size = length // target
segments = [bytes[i * seg_size : (i + 1) * seg_size] for i in range(target)] # fmt: off
segments = [
bytes[i * seg_size : (i + 1) * seg_size] for i in range(target) # noqa: E203 E501
]
# fmt: on
# Catch any left-over bytes in the last segment. # Catch any left-over bytes in the last segment.
segments[-1].extend(bytes[target * seg_size :]) segments[-1].extend(bytes[target * seg_size :]) # noqa: E203 E501
# Use a simple XOR checksum-like function for compression. # Use a simple XOR checksum-like function for compression.
checksum = lambda bytes: reduce(operator.xor, bytes, 0) def checksum(bytes):
return reduce(operator.xor, bytes, 0)
checksums = list(map(checksum, segments)) checksums = list(map(checksum, segments))
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.
Binary file not shown.
+286 -231
View File
@@ -6,7 +6,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-01-15 15:53+0100\n" "POT-Creation-Date: 2023-01-26 01:38+0000\n"
"PO-Revision-Date: 2018-08-05 18:28+0200\n" "PO-Revision-Date: 2018-08-05 18:28+0200\n"
"Last-Translator: Jonas Winkler\n" "Last-Translator: Jonas Winkler\n"
"Language-Team: \n" "Language-Team: \n"
@@ -17,201 +17,202 @@ msgstr ""
"X-Generator: Poedit 2.1.1\n" "X-Generator: Poedit 2.1.1\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: admin.py:40 #: admin.py:43
msgid "Resubmit selected tasks to queue" msgid "Resubmit selected tasks to queue"
msgstr "Ausgewählte Aufgaben erneut ausführen" msgstr "Ausgewählte Aufgaben erneut ausführen"
#: brokers/disque.py:59 #: admin.py:107 models.py:293
msgid "No Disque nodes configured" #, fuzzy
msgstr "Keine Disque-Knoten konfiguriert" #| msgid "Success"
msgid "success"
msgstr "erfolg"
#: brokers/disque.py:76 #: admin.py:119 models.py:295
msgid "Could not connect to any Disque nodes" msgid "last_run"
msgstr "Konnte zu keinem Disque-Knoten verbinden" msgstr ""
#: cluster.py:76 #: cluster.py:79
#, fuzzy, python-brace-format #, python-format
#| msgid "Q Cluster-{} starting." msgid "Q Cluster %(name)s starting."
msgid "Q Cluster {self.name} starting." msgstr "Q-Cluster %(name)s wird gestartet."
msgstr "Q-Cluster {self.name} wird gestartet."
#: cluster.py:84
#, fuzzy, python-brace-format
#| msgid "Q Cluster-{} stopping."
msgid "Q Cluster {self.name} stopping."
msgstr "Q-Cluster {self.name} wird gestoppt."
#: cluster.py:87 #: cluster.py:87
#, fuzzy, python-brace-format #, fuzzy, python-format
#| msgid "Q Cluster-{} has stopped." #| msgid "Q Cluster-{} stopping."
msgid "Q Cluster {self.name} has stopped." msgid "Q Cluster %(name)s stopping."
msgstr "Q-Cluster {self.name} wurde gestoppt." msgstr "Q-Cluster {name} wird gestoppt."
#: cluster.py:95 #: cluster.py:90
msgid "" #, python-format
"{current_process().name} got signal {Conf.SIGNAL_NAMES.get(signum, \"UNKNOWN" msgid "Q Cluster %(name)s has stopped."
"\")}" msgstr "Q-Cluster %(name)s wurde gestoppt."
msgstr ""
"{current_process().name} erhielt das Signal {Conf.SIGNAL_NAMES.get(signum, "
"\"UNKNOWN\")}"
#: cluster.py:216 #: cluster.py:97
#, fuzzy, python-brace-format #, python-format
#| msgid "reincarnated monitor {} after sudden death" msgid "%(name)s got signal %(signal)s"
msgid "reincarnated monitor {process.name} after sudden death" msgstr "%(name)s erhielt das Signal %(signal)s"
msgstr "Monitor {process.name} wurde nach unerwartetem Absturz neu gestartet"
#: cluster.py:219 #: cluster.py:224
#, fuzzy, python-brace-format #, python-format
#| msgid "reincarnated pusher {} after sudden death" msgid "reincarnated monitor %(name)s after sudden death"
msgid "reincarnated pusher {process.name} after sudden death" msgstr "Monitor %(name)s wurde nach unerwartetem Absturz neu gestartet"
msgstr "Pusher {process.name} wurde nach unerwartetem Absturz neu gestartet"
#: cluster.py:226
#, fuzzy, python-brace-format
#| msgid "reincarnated worker {} after timeout"
msgid "reincarnated worker {process.name} after timeout"
msgstr "Worker {process.name} wurde nach Zeitüberschreitung neu gestartet"
#: cluster.py:228
#, fuzzy, python-brace-format
#| msgid "recycled worker {}"
msgid "recycled worker {process.name}"
msgstr "Worker {process.name} wurde wiederverwendet"
#: cluster.py:230 #: cluster.py:230
#, fuzzy, python-brace-format #, python-format
#| msgid "reincarnated worker {} after death" msgid "reincarnated pusher %(name)s after sudden death"
msgid "reincarnated worker {process.name} after death" msgstr "Pusher %(name)s wurde nach unerwartetem Absturz neu gestartet"
msgstr "Worker {process.name} wurde nach unerwartetem Absturz neu gestartet"
#: cluster.py:251 #: cluster.py:250
#, fuzzy, python-format
#| msgid "reincarnated worker %(name)s after timeout"
msgid "" msgid ""
"{current_process().name} guarding cluster {humanize(self.cluster_id.hex)}" "reincarnated worker %(name)s after timeout while processing task "
"%(task_name)s"
msgstr "Worker %(name)s wurde nach Zeitüberschreitung neu gestartet"
#: cluster.py:255
#, python-format
msgid "reincarnated worker %(name)s after timeout"
msgstr "Worker %(name)s wurde nach Zeitüberschreitung neu gestartet"
#: cluster.py:260
#, python-format
msgid "recycled worker %(name)s"
msgstr "Worker %(name)s wurde wiederverwendet"
#: cluster.py:263
#, python-format
msgid "reincarnated worker %(name)s after death"
msgstr "Worker %(name)s wurde nach unerwartetem Absturz neu gestartet"
#: cluster.py:287
#, python-format
msgid "%(name)s guarding cluster %(cluster_name)s"
msgstr "%(name)s beschützt das Cluster %(cluster_name)s"
#: cluster.py:296
#, python-format
msgid "Q Cluster %(cluster_name)s running."
msgstr "Q-Cluster %(cluster_name)s läuft."
#: cluster.py:332
#, python-format
msgid "%(name)s stopping cluster processes"
msgstr "%(name)s hält Cluster-Prozesse an"
#: cluster.py:357
#, python-format
msgid "%(name)s waiting for the monitor."
msgstr "%(name)s wartet auf den Monitor."
#: cluster.py:383
#, fuzzy, python-format
#| msgid "%(process_name)s pushing tasks at %(id)s"
msgid "%(name)s pushing tasks at %(id)s"
msgstr "%(process_name)s veröffentlicht Aufagaben auf %(id)s"
#: cluster.py:407
#, python-format
msgid "queueing from %(list_key)s"
msgstr "Einreihen von %(list_key)s"
#: cluster.py:411
#, python-format
msgid "%(name)s stopped pushing tasks"
msgstr "%(name)s veröffentlicht keine Aufgaben mehr"
#: cluster.py:426
#, python-format
msgid "%(name)s monitoring at %(id)s"
msgstr "%(name)s beobachtet auf %(id)s"
#: cluster.py:445
#, python-format
msgid "Processed '%(info_name)s' (%(task_name)s)"
msgstr "[%(task_name)s] - '%(info_name)s' wurde verarbeitet"
#: cluster.py:451
#, python-format
msgid "Failed '%(info_name)s' (%(task_name)s) - %(task_result)s"
msgstr "'%(info_name)s' (%(task_name)s) ist fehlgeschlagen - %(task_result)s"
#: cluster.py:458
#, python-format
msgid "%(name)s stopped monitoring results"
msgstr "%(name)s überwacht keine Ergebnisse mehr"
#: cluster.py:474
#, python-format
msgid "%(proc_name)s ready for work at %(id)s"
msgstr "%(proc_name)s ist bereit für Arbeit auf %(id)s"
#: cluster.py:494
#, fuzzy, python-format
#| msgid "%(proc_name)s processing '%(func_name)s' (%(task_name)s)"
msgid "%(proc_name)s processing %(task_name)s '%(func_name)s'"
msgstr "%(proc_name)s verarbeitet '%(func_name)s' (%(task_name)s)"
#: cluster.py:546
#, python-format
msgid "%(proc_name)s stopped doing work"
msgstr "%(proc_name)s hat die Arbeit beendet"
#: cluster.py:751
#, python-format
msgid "%(process_name)s failed to create a task from schedule [%(schedule)s]"
msgstr "" msgstr ""
"{current_process().name} beschützt das Cluster {humanize(self.cluster_id." "%(process_name)s konnte keine Aufgabe von Zeitplan [%(schedule)s] erstellen"
"hex)}"
#: cluster.py:256 #: cluster.py:762
msgid "Q Cluster {humanize(self.cluster_id.hex)} running." #, fuzzy, python-format
msgstr "Q-Cluster {humanize(self.cluster_id.hex)} ist bereit." #| msgid "%(process_name)s created a task from schedule [%(schedule)s]"
#: cluster.py:290
#, fuzzy, python-brace-format
#| msgid "{} stopping cluster processes"
msgid "{name} stopping cluster processes"
msgstr "{name} hält Cluster-Prozesse an"
#: cluster.py:315
#, fuzzy, python-brace-format
#| msgid "{} waiting for the monitor."
msgid "{name} waiting for the monitor."
msgstr "{name} wartet auf den Monitor."
#: cluster.py:337
msgid "{current_process().name} pushing tasks at {current_process().pid}"
msgstr ""
"{current_process().name} veröffentlicht Aufagaben auf {current_process().pid}"
#: cluster.py:358
#, fuzzy, python-brace-format
#| msgid "queueing from {}"
msgid "queueing from {broker.list_key}"
msgstr "Einreihen von {broker.list_key}"
#: cluster.py:361
#, fuzzy
#| msgid "{} stopped pushing tasks"
msgid "{current_process().name} stopped pushing tasks"
msgstr "{current_process().name} veröffentlicht keine Aufgaben mehr"
#: cluster.py:373
#, fuzzy
#| msgid "{} monitoring at {}"
msgid "{name} monitoring at {current_process().pid}"
msgstr "{name} beobachtet auf {current_process().pid}"
#: cluster.py:387
#, fuzzy
#| msgid "Processed [{}]"
msgid "Processed [{task['name']}]"
msgstr "[{task['name']}] wurde verarbeitet"
#: cluster.py:390
msgid "Failed [{task['name']}] - {task['result']}"
msgstr "[{task['name']}] ist fehlgeschlagen - {task['result']}"
#: cluster.py:391
#, fuzzy, python-brace-format
#| msgid "{} stopped monitoring results"
msgid "{name} stopped monitoring results"
msgstr "{name} überwacht keine Ergebnisse mehr"
#: cluster.py:405
#, fuzzy
#| msgid "{} ready for work at {}"
msgid "{name} ready for work at {current_process().pid}"
msgstr "{name} ist bereit für Arbeit auf {current_process().pid}"
#: cluster.py:415
#, fuzzy
#| msgid "{} processing [{}]"
msgid "{name} processing [{task[\"name\"]}]"
msgstr "{name} verarbeitet [{task[\"name\"]}]"
#: cluster.py:455
#, fuzzy, python-brace-format
#| msgid "{} stopped doing work"
msgid "{name} stopped doing work"
msgstr "{name} hat die Arbeit beendet"
#: cluster.py:619 models.py:143
msgid "Please install croniter to enable cron expressions"
msgstr "Bitte installieren Sie croniter, um Cron-Ausdrücke zu aktivieren"
#: cluster.py:644
#, fuzzy
#| msgid "{} failed to create a task from schedule [{}]"
msgid "" msgid ""
"{current_process().name} failed to create a task from schedule [{s.name or s." "%(process_name)s created task %(task_name)s from schedule [%(schedule)s]"
"id}]"
msgstr "" msgstr ""
"{current_process().name} konnte keine Aufgabe von Zeitplan [{s.name or s." "%(process_name)s hat eine Aufgabe des Zeitplans [%(schedule)s] erstellt"
"id}] erstellen"
#: cluster.py:650 #: cluster.py:808
#, fuzzy msgid "Skipping cpu affinity because psutil was not found."
#| msgid "{} created a task from schedule [{}]" msgstr "Cpu-Affinität wird übersprungen, da psutil nicht gefunden wurde."
#: cluster.py:813
msgid "Faking cpu affinity because it is not supported on this platform"
msgstr ""
"Vortäuschen von CPU-Affinität, da diese auf dieser Plattform nicht "
"unterstützt wird"
#: cluster.py:835
#, python-format
msgid "%(pid)s will use cpu %(affinity)s"
msgstr "%(pid)s wird CPU %(affinity)s benutzen"
#: conf.py:90
#, python-format
msgid "" msgid ""
"{current_process().name} created a task from schedule [{s.name or s.id}]" "SAVE_LIMIT_PER (%(option)s) is not a valid option. Options are: 'group', "
"'name', 'func' and None. Default is None."
msgstr "" msgstr ""
"{current_process().name} hat eine Aufgabe des Zeitplans [{s.name or s.id}] " "SAVE_LIMIT_PER (%(option)s) ist keine gültige Option. Optionen sind: "
"erstellt" "'group', 'name', 'func' und None. Standard ist None."
#: cluster.py:716
#, fuzzy, python-brace-format
#| msgid "{} will use cpu {}"
msgid "{pid} will use cpu {affinity}"
msgstr "{pid} wird CPU {affinity} benutzen"
#. Translators: Cluster status descriptions #. Translators: Cluster status descriptions
#: conf.py:184 #: conf.py:207
msgid "Starting" msgid "Starting"
msgstr "Wird gestartet" msgstr "Wird gestartet"
#: conf.py:185 #: conf.py:208
msgid "Working" msgid "Working"
msgstr "Arbeitet" msgstr "Arbeitet"
#: conf.py:186 #: conf.py:209
msgid "Idle" msgid "Idle"
msgstr "Leerlauf" msgstr "Leerlauf"
#: conf.py:187 #: conf.py:210
msgid "Stopped" msgid "Stopped"
msgstr "Gestoppt" msgstr "Gestoppt"
#: conf.py:188 #: conf.py:211
msgid "Stopping" msgid "Stopping"
msgstr "Wird gestoppt" msgstr "Wird gestoppt"
@@ -225,223 +226,277 @@ msgstr "Startet ein Django-Q-Cluster."
msgid "General information over all clusters." msgid "General information over all clusters."
msgstr "Allgemeine Informationen über alle Cluster" msgstr "Allgemeine Informationen über alle Cluster"
#. Translators: help text for qmemory management command
#: management/commands/qmemory.py:9
msgid "Monitors Q Cluster memory usage"
msgstr "Überwacht die Speichernutzung von Q Cluster"
#. Translators: help text for qmonitor management command #. Translators: help text for qmonitor management command
#: management/commands/qmonitor.py:9 #: management/commands/qmonitor.py:9
msgid "Monitors Q Cluster activity" msgid "Monitors Q Cluster activity"
msgstr "Q-Cluster aktiv überwachen" msgstr "Q-Cluster aktiv überwachen"
#: models.py:118 #: models.py:125
msgid "Successful task" msgid "Successful task"
msgstr "Erfolgreiche Aufgabe" msgstr "Erfolgreiche Aufgabe"
#: models.py:119 #: models.py:126
msgid "Successful tasks" msgid "Successful tasks"
msgstr "Erfolgreiche Aufgaben" msgstr "Erfolgreiche Aufgaben"
#: models.py:134 #: models.py:141
msgid "Failed task" msgid "Failed task"
msgstr "Fehlgeschlagene Aufgabe" msgstr "Fehlgeschlagene Aufgabe"
#: models.py:135 #: models.py:142
msgid "Failed tasks" msgid "Failed tasks"
msgstr "Fehlgeschlagene Aufgaben" msgstr "Fehlgeschlagene Aufgaben"
#: models.py:159 #: models.py:150 models.py:234
msgid "Please install croniter to enable cron expressions"
msgstr "Bitte installieren Sie croniter, um Cron-Ausdrücke zu aktivieren"
#: models.py:170
msgid "e.g. 1, 2, 'John'" msgid "e.g. 1, 2, 'John'"
msgstr "zum Beispiel 1, 2, 'John'" msgstr "zum Beispiel 1, 2, 'John'"
#: models.py:161 #: models.py:172
msgid "e.g. x=1, y=2, name='John'" msgid "e.g. x=1, y=2, name='John'"
msgstr "zum Beispiel x=1, y=2, name='John'" msgstr "zum Beispiel x=1, y=2, name='John'"
#: models.py:173 #: models.py:186
msgid "Once" msgid "Once"
msgstr "Einmal" msgstr "Einmal"
#: models.py:174 #: models.py:187
msgid "Minutes" msgid "Minutes"
msgstr "Minuten" msgstr "Minuten"
#: models.py:175 #: models.py:188
msgid "Hourly" msgid "Hourly"
msgstr "Stündlich" msgstr "Stündlich"
#: models.py:176 #: models.py:189
msgid "Daily" msgid "Daily"
msgstr "Täglich" msgstr "Täglich"
#: models.py:177 #: models.py:190
msgid "Weekly" msgid "Weekly"
msgstr "Wöchentlich" msgstr "Wöchentlich"
#: models.py:178 #: models.py:191
msgid "Biweekly"
msgstr "Zweiwöchentlich"
#: models.py:192
msgid "Monthly" msgid "Monthly"
msgstr "Monatlich" msgstr "Monatlich"
#: models.py:179 #: models.py:193
msgid "Bimonthly"
msgstr "Zweimonatlich"
#: models.py:194
msgid "Quarterly" msgid "Quarterly"
msgstr "Vierteljährlich" msgstr "Vierteljährlich"
#: models.py:180 #: models.py:195
msgid "Yearly" msgid "Yearly"
msgstr "Jährlich" msgstr "Jährlich"
#: models.py:181 #: models.py:196
msgid "Cron" msgid "Cron"
msgstr "Cron" msgstr "Cron"
#: models.py:184 #: models.py:199
msgid "Schedule Type" msgid "Schedule Type"
msgstr "Zeitplan-Typ" msgstr "Zeitplan-Typ"
#: models.py:187 #: models.py:202
msgid "Number of minutes for the Minutes type" msgid "Number of minutes for the Minutes type"
msgstr "Anzahl Minuten für den Typ 'Minuten'" msgstr "Anzahl Minuten für den Typ 'Minuten'"
#: models.py:190 #: models.py:205
msgid "Repeats" msgid "Repeats"
msgstr "Wiederhohlungen" msgstr "Wiederholungen"
#: models.py:190 #: models.py:205
msgid "n = n times, -1 = forever" msgid "n = n times, -1 = forever"
msgstr "n = n mal, -1 = für immer" msgstr "n = n mal, -1 = für immer"
#: models.py:193 #: models.py:208
msgid "Next Run" msgid "Next Run"
msgstr "Nächste Ausführung" msgstr "Nächste Ausführung"
#: models.py:200 #: models.py:215
msgid "Cron expression" msgid "Cron expression"
msgstr "Cron-Ausdruck" msgstr "Cron-Ausdruck"
#: models.py:226 #: models.py:224
msgid "Name of kwarg to pass intended schedule date"
msgstr "Name des zu passierenden Kwargs vorgesehenes Datum"
#: models.py:299
msgid "Scheduled task" msgid "Scheduled task"
msgstr "Geplante Aufgabe" msgstr "Geplante Aufgabe"
#: models.py:227 #: models.py:300
msgid "Scheduled tasks" msgid "Scheduled tasks"
msgstr "Geplante Aufgaben" msgstr "Geplante Aufgaben"
#: models.py:250 #: models.py:326
msgid "Queued task" msgid "Queued task"
msgstr "Eingereihte Aufgabe" msgstr "Eingereihte Aufgabe"
#: models.py:251 #: models.py:327
msgid "Queued tasks" msgid "Queued tasks"
msgstr "Eingereihte Aufgaben" msgstr "Eingereihte Aufgaben"
#: monitor.py:35 #: monitor.py:64 monitor.py:348
msgid "Host" msgid "Host"
msgstr "Host" msgstr "Host"
#: monitor.py:39 #: monitor.py:68 monitor.py:352 monitor.py:459
msgid "Id" msgid "Id"
msgstr "Id" msgstr "Id"
#: monitor.py:43 #: monitor.py:72
msgid "State" msgid "State"
msgstr "Status" msgstr "Status"
#: monitor.py:47 #: monitor.py:76
msgid "Pool" msgid "Pool"
msgstr "Pool" msgstr "Pool"
#: monitor.py:51 #: monitor.py:80
msgid "TQ" msgid "TQ"
msgstr "TQ" msgstr "TQ"
#: monitor.py:55 #: monitor.py:84
msgid "RQ" msgid "RQ"
msgstr "RQ" msgstr "RQ"
#: monitor.py:59 #: monitor.py:88
msgid "RC" msgid "RC"
msgstr "RC" msgstr "RC"
#: monitor.py:63 #: monitor.py:92
msgid "Up" msgid "Up"
msgstr "Up" msgstr "Up"
#: monitor.py:143 monitor.py:247 #: monitor.py:172 monitor.py:286
msgid "Queued" msgid "Queued"
msgstr "Eingereiht" msgstr "Eingereiht"
#: monitor.py:151 #: monitor.py:180
msgid "Success" msgid "Success"
msgstr "Erfolg" msgstr "Erfolg"
#: monitor.py:161 monitor.py:255 #: monitor.py:190 monitor.py:294
msgid "Failures" msgid "Failures"
msgstr "Fehlschläge" msgstr "Fehlschläge"
#: monitor.py:172 #: monitor.py:201 monitor.py:498
msgid "[Press q to quit]" msgid "[Press q to quit]"
msgstr "[Drücken Sie q zum Beenden]" msgstr "[Drücken Sie q zum Beenden]"
#: monitor.py:191 #: monitor.py:227
msgid "day" msgid "day"
msgstr "Tag" msgstr "Tag"
#: monitor.py:212 #: monitor.py:248
msgid "second" msgid "second"
msgstr "Sekunde" msgstr "Sekunde"
#: monitor.py:215 #: monitor.py:251
msgid "minute" msgid "minute"
msgstr "Minute" msgstr "Minute"
#: monitor.py:218 #: monitor.py:254
msgid "hour" msgid "hour"
msgstr "Stunde" msgstr "Stunde"
#: monitor.py:228 #: monitor.py:263
msgid "" #, python-format
"-- {Conf.PREFIX.capitalize()} { \".\".join(str(v) for v in VERSION)} on " msgid "-- %(prefix)s %(version)s on %(info)s --"
"{broker.info()} --" msgstr "-- %(prefix)s %(version)s auf %(info)s --"
msgstr ""
"-- {Conf.PREFIX.capitalize()} { \".\".join(str(v) for v in VERSION)} auf "
"{broker.info()} --"
#: monitor.py:234 #: monitor.py:273
msgid "Clusters" msgid "Clusters"
msgstr "Cluster" msgstr "Cluster"
#: monitor.py:238 #: monitor.py:277
msgid "Workers" msgid "Workers"
msgstr "Arbeiter" msgstr "Arbeiter"
#: monitor.py:242 #: monitor.py:281
msgid "Restarts" msgid "Restarts"
msgstr "Neustarts" msgstr "Neustarts"
#: monitor.py:251 #: monitor.py:290
msgid "Successes" msgid "Successes"
msgstr "Erfolge" msgstr "Erfolge"
#: monitor.py:260 #: monitor.py:299
msgid "Schedules" msgid "Schedules"
msgstr "Zeitpläne" msgstr "Zeitpläne"
#: monitor.py:264 #: monitor.py:303
#, fuzzy, python-brace-format #, python-format
#| msgid "Tasks/{}" msgid "Tasks/%(per)s"
msgid "Tasks/{per}" msgstr "Aufgaben/%(per)s"
msgstr "Aufgaben/{per}"
#: monitor.py:268 #: monitor.py:307
msgid "Avg time" msgid "Avg time"
msgstr "Durchschnittl. Zeit" msgstr "Durchschnittl. Zeit"
#: monitor.py:357
msgid "Available (%)"
msgstr "Verfügbar (%)"
#: monitor.py:363
msgid "Available (MB)"
msgstr "Verfügbar (MB)"
#: monitor.py:368
msgid "Total (MB)"
msgstr "Insgesamt (MB)"
#: monitor.py:373
msgid "Sentinel (MB)"
msgstr "Sentinel (MB)"
#: monitor.py:379
msgid "Monitor (MB)"
msgstr "Monitor (MB)"
#: monitor.py:385
msgid "Workers (MB)"
msgstr "Arbeiter (MB)"
#: monitor.py:487
#, python-format
msgid "Available lowest (): %(memory_percent)s ((at)s)"
msgstr "Niedrigste verfügbar (): %(memory_percent)s ((at)s)"
#: monitor.py:509
msgid "No clusters appear to be running."
msgstr "Es scheinen keine Cluster zu laufen."
#: signals.py:22 #: signals.py:22
#, fuzzy, python-brace-format #, python-format
#| msgid "malformed return hook '{}' for [{}]" msgid "malformed return hook '%(hook)s' for [%(name)s]"
msgid "malformed return hook '{instance.hook}' for [{instance.name}]" msgstr "Ungültiger Return-Hook '%(hook)s' für [%(name)s]"
msgstr "Ungültiger Return-Hook '{instance.hook}' für [{instance.name}]"
#: signals.py:30 #: signals.py:30
#, fuzzy #, python-format
#| msgid "return hook {} failed on [{}] because {}" msgid "return hook %(hook)s failed on [%(name)s] because %(error)s"
msgid "" msgstr "Return-Hook %(hook)s für [%(name)s] ist gescheitert: %(error)s"
"return hook {instance.hook} failed on [{instance.name}] because {str(e)}"
msgstr "" #, python-format
"Return-Hook {instance.hook} für [{instance.name}] ist gescheitert: {str(e)}" #~ msgid ""
#~ "Could not process '%(func_name)s'. Check the location of the function and "
#~ "the args/kwargs."
#~ msgstr ""
#~ "Konnte '%(func_name)s' nicht verarbeiten. Überprüfen Sie den Ort der "
#~ "Funktion und die args/kwargs."
Binary file not shown.
+307 -152
View File
@@ -6,349 +6,504 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-05 15:48+0200\n" "POT-Creation-Date: 2023-01-26 01:38+0000\n"
"PO-Revision-Date: 2018-08-05 18:28+0200\n" "PO-Revision-Date: 2018-08-05 18:28+0200\n"
"Last-Translator: Thierry BOULOGNE <contact@tng-concepts.com>\n"
"Language-Team: \n" "Language-Team: \n"
"Language: fr-FR\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.1.1\n" "X-Generator: Poedit 2.1.1\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
"Last-Translator: Thierry BOULOGNE <contact@tng-concepts.com>\n"
"Language: fr-FR\n"
#: admin.py:48 #: admin.py:43
msgid "Resubmit selected tasks to queue" msgid "Resubmit selected tasks to queue"
msgstr "Resoumettre les tâches sélectionnées à la file d'attente" msgstr "Resoumettre les tâches sélectionnées à la file d'attente"
#: cluster.py:54 #: admin.py:107 models.py:293
msgid "Q Cluster-{} starting." #, fuzzy
msgstr "Démarrage de Q Cluster-{}." #| msgid "Success"
msgid "success"
msgstr "succès"
#: cluster.py:62 #: admin.py:119 models.py:295
msgid "Q Cluster-{} stopping." msgid "last_run"
msgstr "Arrêt de Q Cluster-{}." msgstr ""
#: cluster.py:65 #: cluster.py:79
msgid "Q Cluster-{} has stopped." #, python-format
msgstr "Q Cluster-{} a été arrêté." msgid "Q Cluster %(name)s starting."
msgstr "Démarrage de Q Cluster-%(name)s."
#: cluster.py:71 #: cluster.py:87
msgid "{} got signal {}" #, python-format
msgstr "{} à reçu le signal {}" msgid "Q Cluster %(name)s stopping."
msgstr "Arrêt de Q Cluster-%(name)s."
#: cluster.py:169 #: cluster.py:90
msgid "reincarnated monitor {} after sudden death" #, python-format
msgstr "moniteur réintégré {} après un arrêt intempestif" msgid "Q Cluster %(name)s has stopped."
msgstr "Q Cluster-%(name)s a été arrêté."
#: cluster.py:172 #: cluster.py:97
msgid "reincarnated pusher {} after sudden death" #, python-format
msgstr "pousseur réintégré {} après un arrêt intempestif" msgid "%(name)s got signal %(signal)s"
msgstr "%(name)s à reçu le signal %(signal)s"
#: cluster.py:179 #: cluster.py:224
msgid "reincarnated worker {} after timeout" #, python-format
msgstr "processus réintégré {} après un arrêt une attente trop longue" msgid "reincarnated monitor %(name)s after sudden death"
msgstr "surveillant %(name)s réincarné après un arrêt intempestif"
#: cluster.py:181 #: cluster.py:230
msgid "recycled worker {}" #, python-format
msgstr "processus recyclé" msgid "reincarnated pusher %(name)s after sudden death"
msgstr "répartiteur %(name)s réincarné après un arrêt intempestif"
#: cluster.py:183 #: cluster.py:250
msgid "reincarnated worker {} after death" #, python-format
msgstr "processus réintégré {} après arrêt" msgid ""
"reincarnated worker %(name)s after timeout while processing task "
"%(task_name)s"
msgstr ""
"processus %(name)s réincarné, délai de traitement dépassé pour la tâche "
"%(task_name)s"
#: cluster.py:202 #: cluster.py:255
msgid "{} guarding cluster at {}" #, python-format
msgstr "{} surveillance du cluster à {}" msgid "reincarnated worker %(name)s after timeout"
msgstr "processus %(name)s réincarné, délai de traitement dépassé"
#: cluster.py:205 #: cluster.py:260
msgid "Q Cluster-{} running." #, python-format
msgstr "Q Cluster-{} en cours d'exécution." msgid "recycled worker %(name)s"
msgstr "processus recyclé %(name)s"
#: cluster.py:239 #: cluster.py:263
msgid "{} stopping cluster processes" #, python-format
msgstr "{} arrêt des processus de cluster" msgid "reincarnated worker %(name)s after death"
msgstr "processus réintégré %(name)s après arrêt"
#: cluster.py:264 #: cluster.py:287
msgid "{} waiting for the monitor." #, python-format
msgstr "{} en attente du moniteur." msgid "%(name)s guarding cluster %(cluster_name)s"
msgstr "%(name)s surveillance du cluster à %(cluster_name)s"
#: cluster.py:285 #: cluster.py:296
msgid "{} pushing tasks at {}" #, python-format
msgstr "{} tâche envoyé à {}" msgid "Q Cluster %(cluster_name)s running."
msgstr "Démarrage de Q Cluster-%(cluster_name)s."
#: cluster.py:306 #: cluster.py:332
msgid "queueing from {}" #, python-format
msgstr "mise en file d'attente de {}" msgid "%(name)s stopping cluster processes"
msgstr "%(name)s arrêt des processus du cluster"
#: cluster.py:309
msgid "{} stopped pushing tasks"
msgstr "{} a cessé de pousser les tâches"
#: cluster.py:320
msgid "{} monitoring at {}"
msgstr "{} Surveillance de {}"
#: cluster.py:334
msgid "Processed [{}]"
msgstr "Traitement de [{}]"
#: cluster.py:337
msgid "Failed [{}] - {}"
msgstr "Echec [{}] - {}"
#: cluster.py:338
msgid "{} stopped monitoring results"
msgstr "{} arrêt des résultats de surveillance"
#: cluster.py:349
msgid "{} ready for work at {}"
msgstr "{} prêt pour le travail à {}"
#: cluster.py:357 #: cluster.py:357
msgid "{} processing [{}]" #, python-format
msgstr "{} en cours de traitement [{}]" msgid "%(name)s waiting for the monitor."
msgstr "%(name)s en attente du surveillant."
#: cluster.py:398 #: cluster.py:383
msgid "{} stopped doing work" #, python-format
msgstr "{} arrêté de travailler" msgid "%(name)s pushing tasks at %(id)s"
msgstr "%(name)s répartit les tâches %(id)s"
#: cluster.py:543 #: cluster.py:407
msgid "{} failed to create a task from schedule [{}]" #, python-format
msgstr "{} Echec de la création d'une tâche à partir de Schedule [{}]" msgid "queueing from %(list_key)s"
msgstr "mise en file d'attente de %(list_key)s"
#: cluster.py:547 #: cluster.py:411
msgid "{} created a task from schedule [{}]" #, python-format
msgstr "{} a créé une tâche à partir de Schedule [{}]" msgid "%(name)s stopped pushing tasks"
msgstr "%(name)s a cessé de répartir les tâches"
#: cluster.py:595 #: cluster.py:426
msgid "{} will use cpu {}" #, python-format
msgstr "{} utilisera le CPU {}" msgid "%(name)s monitoring at %(id)s"
msgstr "%(name)s surveille les résultats %(id)s"
#: conf.py:169 #: cluster.py:445
#, python-format
msgid "Processed '%(info_name)s' (%(task_name)s)"
msgstr "traité '%(info_name)s' (%(task_name)s)"
#: cluster.py:451
#, python-format
msgid "Failed '%(info_name)s' (%(task_name)s) - %(task_result)s"
msgstr "Manqué '%(info_name)s' (%(task_name)s) - %(task_result)s"
#: cluster.py:458
#, python-format
msgid "%(name)s stopped monitoring results"
msgstr "%(name)s a cessé de de surveiller les résultats"
#: cluster.py:474
#, python-format
msgid "%(proc_name)s ready for work at %(id)s"
msgstr "%(proc_name)s prêt pour le travail à %(id)s"
#: cluster.py:494
#, fuzzy, python-format
msgid "%(proc_name)s processing %(task_name)s '%(func_name)s'"
msgstr "%(proc_name)s exécute %(task_name)s '%(func_name)s'"
#: cluster.py:546
#, python-format
msgid "%(proc_name)s stopped doing work"
msgstr "%(proc_name)s a cessé de travailler"
#: cluster.py:751
#, python-format
msgid "%(process_name)s failed to create a task from schedule [%(schedule)s]"
msgstr ""
"%(process_name)s Echec de la création d'une tâche à partir de Schedule "
"[%(schedule)s]"
#: cluster.py:762
#, python-format
msgid ""
"%(process_name)s created task %(task_name)s from schedule [%(schedule)s]"
msgstr ""
"%(process_name)s a créé la tâche %(task_name)s à partir de Schedule "
"[%(schedule)s]"
#: cluster.py:808
msgid "Skipping cpu affinity because psutil was not found."
msgstr "L'affinité cpu ne sera pas définie car psutil n'a pas été trouvé."
#: cluster.py:813
msgid "Faking cpu affinity because it is not supported on this platform"
msgstr ""
"Simulation de l'affinité cpu parce qu'elle n'est pas supportée sur cette "
"plateforme."
#: cluster.py:835
#, python-format
msgid "%(pid)s will use cpu %(affinity)s"
msgstr "%(pid)s utilisera le CPU %(affinity)s"
#: conf.py:90
#, python-format
msgid ""
"SAVE_LIMIT_PER (%(option)s) is not a valid option. Options are: 'group', "
"'name', 'func' and None. Default is None."
msgstr ""
"SAVE_LIMIT_PER (%(option)s) n'est pas une option valide. Les options sont : "
"'group', 'name', 'func' et None. La valeur par défaut est None."
#. Translators: Cluster status descriptions
#: conf.py:207
msgid "Starting" msgid "Starting"
msgstr "Démarrage" msgstr "Démarrage"
#: conf.py:170 #: conf.py:208
msgid "Working" msgid "Working"
msgstr "Actif" msgstr "Actif"
#: conf.py:171 #: conf.py:209
msgid "Idle" msgid "Idle"
msgstr "En attente" msgstr "En attente"
#: conf.py:172 #: conf.py:210
msgid "Stopped" msgid "Stopped"
msgstr "Arrêté" msgstr "Arrêté"
#: conf.py:173 #: conf.py:211
msgid "Stopping" msgid "Stopping"
msgstr "En cours d’arrêt" msgstr "En cours d’arrêt"
#. Translators: help text for qcluster management command
#: management/commands/qcluster.py:9 #: management/commands/qcluster.py:9
msgid "Starts a Django Q Cluster." msgid "Starts a Django Q Cluster."
msgstr "Démarre un cluster Django Q." msgstr "Démarre un cluster Django Q."
#. Translators: help text for qinfo management command
#: management/commands/qinfo.py:11 #: management/commands/qinfo.py:11
msgid "General information over all clusters." msgid "General information over all clusters."
msgstr "Informations générales sur tous les clusters." msgstr "Informations générales sur tous les clusters."
#. Translators: help text for qmemory management command
#: management/commands/qmemory.py:9
#, fuzzy
#| msgid "Monitors Q Cluster activity"
msgid "Monitors Q Cluster memory usage"
msgstr "Surveille l'utilisation mémoire du Q cluster"
#. Translators: help text for qmonitor management command
#: management/commands/qmonitor.py:9 #: management/commands/qmonitor.py:9
msgid "Monitors Q Cluster activity" msgid "Monitors Q Cluster activity"
msgstr "Activité du cluster Moniteur Q" msgstr "Surveille l'activité de Q cluster"
#: models.py:104 #: models.py:125
msgid "Successful task" msgid "Successful task"
msgstr "Tâche réussie" msgstr "Tâche réussie"
#: models.py:105 #: models.py:126
msgid "Successful tasks" msgid "Successful tasks"
msgstr "Tâches réussies" msgstr "Tâches réussies"
#: models.py:121 #: models.py:141
msgid "Failed task" msgid "Failed task"
msgstr "Tâche échoué" msgstr "Tâche échoué"
#: models.py:122 #: models.py:142
msgid "Failed tasks" msgid "Failed tasks"
msgstr "Tâches échouées" msgstr "Tâches échouées"
#: models.py:131 #: models.py:150 models.py:234
msgid "Please install croniter to enable cron expressions"
msgstr "Veuillez installer croniter pour activer les expressions cron."
#: models.py:170
msgid "e.g. 1, 2, 'John'" msgid "e.g. 1, 2, 'John'"
msgstr "ex. 1, 2, ‘Jean’" msgstr "ex. 1, 2, ‘Jean’"
#: models.py:132 #: models.py:172
msgid "e.g. x=1, y=2, name='John'" msgid "e.g. x=1, y=2, name='John'"
msgstr "p. ex. x = 1, y = 2, Nom = ‘Jean’" msgstr "p. ex. x = 1, y = 2, Nom = ‘Jean’"
#: models.py:142 #: models.py:186
msgid "Once" msgid "Once"
msgstr "Une fois" msgstr "Une fois"
#: models.py:143 #: models.py:187
msgid "Minutes" msgid "Minutes"
msgstr "Minutes" msgstr "Minutes"
#: models.py:144 #: models.py:188
msgid "Hourly" msgid "Hourly"
msgstr "Toutes les heures" msgstr "Toutes les heures"
#: models.py:145 #: models.py:189
msgid "Daily" msgid "Daily"
msgstr "Quotidien" msgstr "Quotidien"
#: models.py:146 #: models.py:190
msgid "Weekly" msgid "Weekly"
msgstr "Hebdomadaire" msgstr "Hebdomadaire"
#: models.py:147 #: models.py:191
#, fuzzy
#| msgid "Weekly"
msgid "Biweekly"
msgstr "Bihebdomadaire"
#: models.py:192
msgid "Monthly" msgid "Monthly"
msgstr "Mensuel" msgstr "Mensuel"
#: models.py:148 #: models.py:193
msgid "Quarterly" #, fuzzy
msgstr "Tous les quart-d’heure" #| msgid "Monthly"
msgid "Bimonthly"
msgstr "Bimestriel"
#: models.py:149 #: models.py:194
msgid "Quarterly"
msgstr "Trimestriel"
#: models.py:195
msgid "Yearly" msgid "Yearly"
msgstr "Annuel" msgstr "Annuel"
#: models.py:151 #: models.py:196
msgid "Cron"
msgstr "Cron"
#: models.py:199
msgid "Schedule Type" msgid "Schedule Type"
msgstr "Type de plannification" msgstr "Type de plannification"
#: models.py:153 #: models.py:202
msgid "Number of minutes for the Minutes type" msgid "Number of minutes for the Minutes type"
msgstr "Nombre de minutes pour le type de minutes" msgstr "Nombre de minutes pour le type de minutes"
#: models.py:154 #: models.py:205
msgid "Repeats" msgid "Repeats"
msgstr "Répéter" msgstr "Répéter"
#: models.py:154 #: models.py:205
msgid "n = n times, -1 = forever" msgid "n = n times, -1 = forever"
msgstr "n = n fois,-1 = Toujours" msgstr "n = n fois,-1 = Toujours"
#: models.py:155 #: models.py:208
msgid "Next Run" msgid "Next Run"
msgstr "Prochaine exécution" msgstr "Prochaine exécution"
#: models.py:180 #: models.py:215
msgid "Cron expression"
msgstr "Expression Cron"
#: models.py:224
msgid "Name of kwarg to pass intended schedule date"
msgstr "Nom du kwarg à passer Date prévue de l'horaire"
#: models.py:299
msgid "Scheduled task" msgid "Scheduled task"
msgstr "Tâche planifiée" msgstr "Tâche planifiée"
#: models.py:181 #: models.py:300
msgid "Scheduled tasks" msgid "Scheduled tasks"
msgstr "Tâches planifiées" msgstr "Tâches planifiées"
#: models.py:204 #: models.py:326
msgid "Queued task" msgid "Queued task"
msgstr "Tâche en file d'attente" msgstr "Tâche en file d'attente"
#: models.py:205 #: models.py:327
msgid "Queued tasks" msgid "Queued tasks"
msgstr "Tâches en file d'attente" msgstr "Tâches en file d'attente"
#: monitor.py:33 #: monitor.py:64 monitor.py:348
msgid "Host" msgid "Host"
msgstr "Hôte" msgstr "Hôte"
#: monitor.py:34 #: monitor.py:68 monitor.py:352 monitor.py:459
msgid "Id" msgid "Id"
msgstr "Id" msgstr "Id"
#: monitor.py:35 #: monitor.py:72
msgid "State" msgid "State"
msgstr "Statut" msgstr "Statut"
#: monitor.py:36 #: monitor.py:76
msgid "Pool" msgid "Pool"
msgstr "Piscine" msgstr "Piscine"
#: monitor.py:37 #: monitor.py:80
msgid "TQ" msgid "TQ"
msgstr "TQ" msgstr "TQ"
#: monitor.py:38 #: monitor.py:84
msgid "RQ" msgid "RQ"
msgstr "RQ" msgstr "RQ"
#: monitor.py:39 #: monitor.py:88
msgid "RC" msgid "RC"
msgstr "RC" msgstr "RC"
#: monitor.py:40 #: monitor.py:92
msgid "Up" msgid "Up"
msgstr "Haut" msgstr "Haut"
#: monitor.py:90 monitor.py:165 #: monitor.py:172 monitor.py:286
msgid "Queued" msgid "Queued"
msgstr "En file d'attente" msgstr "En file d'attente"
#: monitor.py:92 #: monitor.py:180
msgid "Success" msgid "Success"
msgstr "Succès" msgstr "Succès"
#: monitor.py:95 monitor.py:173 #: monitor.py:190 monitor.py:294
msgid "Failures" msgid "Failures"
msgstr "Défaillances" msgstr "Défaillances"
#: monitor.py:101 #: monitor.py:201 monitor.py:498
msgid "[Press q to quit]" msgid "[Press q to quit]"
msgstr "[appuyez sur q pour quitter]" msgstr "[appuyez sur q pour quitter]"
#: monitor.py:120 #: monitor.py:227
msgid "day" msgid "day"
msgstr "jour" msgstr "jour"
#: monitor.py:137 #: monitor.py:248
msgid "second" msgid "second"
msgstr "seconde" msgstr "seconde"
#: monitor.py:140 #: monitor.py:251
msgid "minute" msgid "minute"
msgstr "minute" msgstr "minute"
#: monitor.py:143 #: monitor.py:254
msgid "hour" msgid "hour"
msgstr "heure" msgstr "heure"
#: monitor.py:151 #: monitor.py:263
msgid "-- {} {} on {} --" #, python-format
msgstr "--{} {} sur {}--" msgid "-- %(prefix)s %(version)s on %(info)s --"
msgstr "--%(prefix)s %(version)s sur %(info)s --"
#: monitor.py:153 #: monitor.py:273
msgid "Clusters" msgid "Clusters"
msgstr "Grappes" msgstr "Grappes"
#: monitor.py:157 #: monitor.py:277
msgid "Workers" msgid "Workers"
msgstr "Processus" msgstr "Processus"
#: monitor.py:161 #: monitor.py:281
msgid "Restarts" msgid "Restarts"
msgstr "Redémarrages" msgstr "Redémarrages"
#: monitor.py:169 #: monitor.py:290
msgid "Successes" msgid "Successes"
msgstr "Succès" msgstr "Succès"
#: monitor.py:177 #: monitor.py:299
msgid "Schedules" msgid "Schedules"
msgstr "Planifications" msgstr "Planifications"
#: monitor.py:181 #: monitor.py:303
msgid "Tasks/{}" #, python-format
msgstr "Tâches/{}" msgid "Tasks/%(per)s"
msgstr "Tâches/%(per)s"
#: monitor.py:185 #: monitor.py:307
msgid "Avg time" msgid "Avg time"
msgstr "Temps Moyen" msgstr "Temps Moyen"
#: signals.py:21 #: monitor.py:357
msgid "malformed return hook '{}' for [{}]" msgid "Available (%)"
msgstr "hook de retour mal formé' {} 'pour [{}]" msgstr ""
#: signals.py:26 #: monitor.py:363
msgid "return hook {} failed on [{}] because {}" msgid "Available (MB)"
msgstr "le crochet de retour {} a échoué sur [{}] parce que {}" msgstr "Disponible sur (MB)"
#: monitor.py:368
msgid "Total (MB)"
msgstr "Total (MB)"
#: monitor.py:373
msgid "Sentinel (MB)"
msgstr "Sentinel (MB)"
#: monitor.py:379
msgid "Monitor (MB)"
msgstr "Monitor (MB)"
#: monitor.py:385
#, fuzzy
#| msgid "Workers"
msgid "Workers (MB)"
msgstr "Processus (MB)"
#: monitor.py:487
#, python-format
msgid "Available lowest (): %(memory_percent)s ((at)s)"
msgstr "Disponible le plus bas () : %(memory_percent)s ((at)s)"
#: monitor.py:509
msgid "No clusters appear to be running."
msgstr "Aucun cluster ne semble être en cours d'exécution."
#: signals.py:22
#, python-format
msgid "malformed return hook '%(hook)s' for [%(name)s]"
msgstr "hook de retour '%(hook)s' mal formé pour [%(name)s]"
#: signals.py:30
#, python-format
msgid "return hook %(hook)s failed on [%(name)s] because %(error)s"
msgstr "hook de retour %(hook)s a échoué sur [%(name)s] à cause de %(error)s"
#, python-format
#~ msgid ""
#~ "Could not process '%(func_name)s'. Check the location of the function and "
#~ "the args/kwargs."
#~ msgstr ""
#~ "Impossible de traiter '%(func_name)s'. Vérifiez l'emplacement de la "
#~ "fonction et les args/kwargs."
+263 -199
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-06-18 23:55+0300\n" "POT-Creation-Date: 2023-01-26 01:38+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Ethem Güner <ethemguener@gmail.com>\n" "Last-Translator: Ethem Güner <ethemguener@gmail.com>\n"
"Language-Team: \n" "Language-Team: \n"
@@ -17,422 +17,486 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: django_q/admin.py:40
#: admin.py:43
msgid "Resubmit selected tasks to queue" msgid "Resubmit selected tasks to queue"
msgstr "Seçili işleri kuyruğa tekrar gönder" msgstr "Seçili işleri kuyruğa tekrar gönder"
#: django_q/brokers/disque.py:60 #: admin.py:107 models.py:293
msgid "No Disque nodes configured" #, fuzzy
msgstr "Disque nodları konfigüre edilmemiş" #| msgid "Success"
msgid "success"
msgstr "başarılı olanlar"
#: django_q/brokers/disque.py:77 #: admin.py:119 models.py:295
msgid "Could not connect to any Disque nodes" msgid "last_run"
msgstr "Herhangi bir Disque noduna bağlanılamadı" msgstr ""
#: django_q/cluster.py:79 #: cluster.py:79
#, python-brace-format #, python-format
msgid "Q Cluster {self.name} starting." msgid "Q Cluster %(name)s starting."
msgstr "Q Cluster {self.name} başlatılıyor." msgstr "Q Cluster %(name)s başlatılıyor."
#: django_q/cluster.py:87 #: cluster.py:87
#, python-brace-format #, python-format
msgid "Q Cluster {self.name} stopping." msgid "Q Cluster %(name)s stopping."
msgstr "Q Cluster {self.name} durduruluyor." msgstr "Q Cluster %(name)s durduruluyor."
#: django_q/cluster.py:90 #: cluster.py:90
#, python-brace-format #, python-format
msgid "Q Cluster {self.name} has stopped." msgid "Q Cluster %(name)s has stopped."
msgstr "Q Cluster {self.name} durduruldu." msgstr "Q Cluster %(name)s durduruldu."
#: django_q/cluster.py:98 #: cluster.py:97
#, python-format
msgid "%(name)s got signal %(signal)s"
msgstr "%(name)s, %(signal)s pid'inde izleniyor/monitoring yapılıyor."
#: cluster.py:224
#, python-format
msgid "reincarnated monitor %(name)s after sudden death"
msgstr "Monitor %(name)s ani ölüm sonrası tekrar dirildi"
#: cluster.py:230
#, python-format
msgid "reincarnated pusher %(name)s after sudden death"
msgstr "Pusher %(name)s ani ölüm sonrası tekrar dirildi"
#: cluster.py:250
#, fuzzy, python-format
#| msgid "reincarnated worker %(name)s after timeout"
msgid "" msgid ""
"{current_process().name} got signal {Conf.SIGNAL_NAMES.get(signum, \"UNKNOWN" "reincarnated worker %(name)s after timeout while processing task "
"\")}" "%(task_name)s"
msgstr "Worker %(name)s zaman aşımı sonrası tekrar dirildi"
#: cluster.py:255
#, python-format
msgid "reincarnated worker %(name)s after timeout"
msgstr "Worker %(name)s zaman aşımı sonrası tekrar dirildi"
#: cluster.py:260
#, python-format
msgid "recycled worker %(name)s"
msgstr "Worker %(name)s geri döndürüldü"
#: cluster.py:263
#, python-format
msgid "reincarnated worker %(name)s after death"
msgstr "Worker %(name)s ani ölüm sonrası tekrar dirildi"
#: cluster.py:287
#, python-format
msgid "%(name)s guarding cluster %(cluster_name)s"
msgstr "%(name)s, %(cluster_name)s cluster'ını koruyor"
#: cluster.py:296
#, python-format
msgid "Q Cluster %(cluster_name)s running."
msgstr "Q Cluster %(cluster_name)s başlatılıyor."
#: cluster.py:332
#, python-format
msgid "%(name)s stopping cluster processes"
msgstr "Cluster %(name)s işlemleri durduruluyor."
#: cluster.py:357
#, python-format
msgid "%(name)s waiting for the monitor."
msgstr "%(name)s monitor için bekliyor."
#: cluster.py:383
#, fuzzy, python-format
#| msgid "%(process_name)s pushing tasks at %(id)s"
msgid "%(name)s pushing tasks at %(id)s"
msgstr "%(process_name)s, işleri %(id)s pid'ine gönderiyor."
#: cluster.py:407
#, python-format
msgid "queueing from %(list_key)s"
msgstr "" msgstr ""
"{current_process().name} şu sinyali aldı {Conf.SIGNAL_NAMES.get(signum, \"UNKNOWN"
"\")}"
#: django_q/cluster.py:220 #: cluster.py:411
#, python-brace-format #, python-format
msgid "reincarnated monitor {process.name} after sudden death" msgid "%(name)s stopped pushing tasks"
msgstr "Monitor {process.name} ani ölüm sonrası tekrar dirildi" msgstr "%(name)s işleri göndermeyi durdurdu"
#: django_q/cluster.py:223 #: cluster.py:426
#, python-brace-format #, python-format
msgid "reincarnated pusher {process.name} after sudden death" msgid "%(name)s monitoring at %(id)s"
msgstr "Pusher {process.name} ani ölüm sonrası tekrar dirildi" msgstr "%(name)s, %(id)s pid'inde izleniyor/monitoring yapılıyor."
#: django_q/cluster.py:230 #: cluster.py:445
#, python-brace-format #, python-format
msgid "reincarnated worker {process.name} after timeout" msgid "Processed '%(info_name)s' (%(task_name)s)"
msgstr "Worker {process.name} zaman aşımı sonrası tekrar dirildi" msgstr "[%(task_name)s] - '%(info_name)s işlendi."
#: django_q/cluster.py:232 #: cluster.py:451
#, python-brace-format #, python-format
msgid "recycled worker {process.name}" msgid "Failed '%(info_name)s' (%(task_name)s) - %(task_result)s"
msgstr "Worker {process.name} geri döndürüldü" msgstr "[%(task_name)s] - '%(info_name)s' - %(task_result)s başarısız oldu"
#: django_q/cluster.py:234 #: cluster.py:458
#, python-brace-format #, python-format
msgid "reincarnated worker {process.name} after death" msgid "%(name)s stopped monitoring results"
msgstr "Worker {process.name} ani ölüm sonrası tekrar dirildi" msgstr "%(name)s sonuçları göstermeyi bıraktı"
#: django_q/cluster.py:256 #: cluster.py:474
#, python-format
msgid "%(proc_name)s ready for work at %(id)s"
msgstr "%(proc_name)s, %(id)s pid'inde çalışmaya hazır"
#: cluster.py:494
#, fuzzy, python-format
#| msgid "%(proc_name)s processing '%(func_name)s' (%(task_name)s)"
msgid "%(proc_name)s processing %(task_name)s '%(func_name)s'"
msgstr "%(proc_name)s, '%(func_name)s' [%(task_name)s] işlerini işiyor"
#: cluster.py:546
#, python-format
msgid "%(proc_name)s stopped doing work"
msgstr "%(proc_name)s çalışmayı bıraktı"
#: cluster.py:751
#, python-format
msgid "%(process_name)s failed to create a task from schedule [%(schedule)s]"
msgstr "%(process_name)s programdan bir görev oluşturamadı [%(schedule)s]"
#: cluster.py:762
#, fuzzy, python-format
#| msgid "%(process_name)s created a task from schedule [%(schedule)s]"
msgid "" msgid ""
"{current_process().name} guarding cluster {humanize(self.cluster_id.hex)}" "%(process_name)s created task %(task_name)s from schedule [%(schedule)s]"
msgstr "{current_process().name}, {humanize(self.cluster_id.hex)} cluster'ını koruyor" msgstr "%(process_name)s programdan bir görev oluşturamadı [%(schedule)s]"
#: django_q/cluster.py:261 #: cluster.py:808
msgid "Q Cluster {humanize(self.cluster_id.hex)} running." msgid "Skipping cpu affinity because psutil was not found."
msgstr "Q Cluster {humanize(self.cluster_id.hex)} çalışıyor." msgstr "Psutil bulunamadığı için cpu benzeşimi atlanıyor."
#: django_q/cluster.py:295 #: cluster.py:813
#, python-brace-format msgid "Faking cpu affinity because it is not supported on this platform"
msgid "{name} stopping cluster processes" msgstr "Bu platformda desteklenmediği için sahte cpu benzeşimi"
msgstr "Cluster {name} işlemleri durduruluyor."
#: django_q/cluster.py:320 #: cluster.py:835
#, python-brace-format #, python-format
msgid "{name} waiting for the monitor." msgid "%(pid)s will use cpu %(affinity)s"
msgstr "{name} monitor için bekliyor." msgstr "%(pid)s cpu %(affinity)s kullanacaktır"
#: django_q/cluster.py:342 #: conf.py:90
msgid "{current_process().name} pushing tasks at {current_process().pid}" #, python-format
msgstr "{current_process().name, işleri {current_process().pid} pid'ine gönderiyor."
#: django_q/cluster.py:363
#, python-brace-format
msgid "queueing from {broker.list_key}"
msgstr ""
#: django_q/cluster.py:366
msgid "{current_process().name} stopped pushing tasks"
msgstr "{current_process().name} işleri göndermeyi durdurdu"
#: django_q/cluster.py:378
msgid "{name} monitoring at {current_process().pid}"
msgstr "{name}, {current_process().pid} pid'inde izleniyor/monitoring yapılıyor."
#: django_q/cluster.py:394
msgid "Processed [{task['name']}]"
msgstr "[{task['name']}] işlendi."
#: django_q/cluster.py:397
msgid "Failed [{task['name']}] - {task['result']}"
msgstr "[{task['name']}] - {task['result']} başarısız oldu"
#: django_q/cluster.py:398
#, python-brace-format
msgid "{name} stopped monitoring results"
msgstr "{name} sonuçları göstermeyi bıraktı"
#: django_q/cluster.py:412
msgid "{name} ready for work at {current_process().pid}"
msgstr "{name}, {current_process().pid} pid'inde çalışmaya hazır"
#: django_q/cluster.py:422
msgid "{name} processing [{task[\"name\"]}]"
msgstr "{name}, [{task[\"name\"]}] işlerini işiyor"
#: django_q/cluster.py:453
#, python-brace-format
msgid "{name} stopped doing work"
msgstr "{name} çalışmayı bıraktı"
#: django_q/cluster.py:635 django_q/models.py:143
msgid "Please install croniter to enable cron expressions"
msgstr "Cron expressions'ları açmak için croniter yükleyin"
#: django_q/cluster.py:665
msgid "" msgid ""
"{current_process().name} failed to create a task from schedule [{s.name or s." "SAVE_LIMIT_PER (%(option)s) is not a valid option. Options are: 'group', "
"id}]" "'name', 'func' and None. Default is None."
msgstr ""
#: django_q/cluster.py:671
msgid ""
"{current_process().name} created a task from schedule [{s.name or s.id}]"
msgstr ""
#: django_q/cluster.py:737
#, python-brace-format
msgid "{pid} will use cpu {affinity}"
msgstr "" msgstr ""
"SAVE_LIMIT_PER (%(option)s) geçerli bir seçenek değil. Seçenekler şunlardır: "
"'group', 'name', 'func' ve None. Varsayılan değer None'dır."
#. Translators: Cluster status descriptions #. Translators: Cluster status descriptions
#: django_q/conf.py:196 #: conf.py:207
msgid "Starting" msgid "Starting"
msgstr "Başlıyor" msgstr "Başlıyor"
#: django_q/conf.py:197 #: conf.py:208
msgid "Working" msgid "Working"
msgstr "Çalışıyor" msgstr "Çalışıyor"
#: django_q/conf.py:198 #: conf.py:209
msgid "Idle" msgid "Idle"
msgstr "Boşta" msgstr "Boşta"
#: django_q/conf.py:199 #: conf.py:210
msgid "Stopped" msgid "Stopped"
msgstr "Durdu" msgstr "Durdu"
#: django_q/conf.py:200 #: conf.py:211
msgid "Stopping" msgid "Stopping"
msgstr "Durduruluyor" msgstr "Durduruluyor"
#. Translators: help text for qcluster management command #. Translators: help text for qcluster management command
#: django_q/management/commands/qcluster.py:9 #: management/commands/qcluster.py:9
msgid "Starts a Django Q Cluster." msgid "Starts a Django Q Cluster."
msgstr "Bir Django Q Cluster çalıştırır." msgstr "Bir Django Q Cluster çalıştırır."
#. Translators: help text for qinfo management command #. Translators: help text for qinfo management command
#: django_q/management/commands/qinfo.py:11 #: management/commands/qinfo.py:11
msgid "General information over all clusters." msgid "General information over all clusters."
msgstr "Tüm cluster'lar için genel bilgiler." msgstr "Tüm cluster'lar için genel bilgiler."
#. Translators: help text for qmemory management command #. Translators: help text for qmemory management command
#: django_q/management/commands/qmemory.py:9 #: management/commands/qmemory.py:9
msgid "Monitors Q Cluster memory usage" msgid "Monitors Q Cluster memory usage"
msgstr "Q Cluster'ın bellek kullanımını izler" msgstr "Q Cluster'ın bellek kullanımını izler"
#. Translators: help text for qmonitor management command #. Translators: help text for qmonitor management command
#: django_q/management/commands/qmonitor.py:9 #: management/commands/qmonitor.py:9
msgid "Monitors Q Cluster activity" msgid "Monitors Q Cluster activity"
msgstr "Q Cluster'ın aktivitelerini izler" msgstr "Q Cluster'ın aktivitelerini izler"
#: django_q/models.py:118 #: models.py:125
msgid "Successful task" msgid "Successful task"
msgstr "Başarılı iş" msgstr "Başarılı iş"
#: django_q/models.py:119 #: models.py:126
msgid "Successful tasks" msgid "Successful tasks"
msgstr "Başarılı işler" msgstr "Başarılı işler"
#: django_q/models.py:134 #: models.py:141
msgid "Failed task" msgid "Failed task"
msgstr "Başarısız iş" msgstr "Başarısız iş"
#: django_q/models.py:135 #: models.py:142
msgid "Failed tasks" msgid "Failed tasks"
msgstr "Başarısız işler" msgstr "Başarısız işler"
#: django_q/models.py:159 #: models.py:150 models.py:234
msgid "Please install croniter to enable cron expressions"
msgstr "Cron expressions'ları açmak için croniter yükleyin"
#: models.py:170
msgid "e.g. 1, 2, 'John'" msgid "e.g. 1, 2, 'John'"
msgstr "Örneğin: 1, 2, 'Melih'" msgstr "Örneğin: 1, 2, 'Melih'"
#: django_q/models.py:161 #: models.py:172
msgid "e.g. x=1, y=2, name='John'" msgid "e.g. x=1, y=2, name='John'"
msgstr "Örneğin: x=1, y=2, name='Melih'" msgstr "Örneğin: x=1, y=2, name='Melih'"
#: django_q/models.py:173 #: models.py:186
msgid "Once" msgid "Once"
msgstr "Bir kere" msgstr "Bir kere"
#: django_q/models.py:174 #: models.py:187
msgid "Minutes" msgid "Minutes"
msgstr "Dakika" msgstr "Dakika"
#: django_q/models.py:175 #: models.py:188
msgid "Hourly" msgid "Hourly"
msgstr "Saatlik" msgstr "Saatlik"
#: django_q/models.py:176 #: models.py:189
msgid "Daily" msgid "Daily"
msgstr "Günlük" msgstr "Günlük"
#: django_q/models.py:177 #: models.py:190
msgid "Weekly" msgid "Weekly"
msgstr "Haftalık" msgstr "Haftalık"
#: django_q/models.py:178 #: models.py:191
#, fuzzy
#| msgid "Weekly"
msgid "Biweekly"
msgstr "İki haftada bir"
#: models.py:192
msgid "Monthly" msgid "Monthly"
msgstr "Aylık" msgstr "Aylık"
#: django_q/models.py:179 #: models.py:193
#, fuzzy
#| msgid "Monthly"
msgid "Bimonthly"
msgstr "İki ayda bir"
#: models.py:194
msgid "Quarterly" msgid "Quarterly"
msgstr "Bir Çeyrek (3 Ay)" msgstr "Bir Çeyrek (3 Ay)"
#: django_q/models.py:180 #: models.py:195
msgid "Yearly" msgid "Yearly"
msgstr "Yıllık" msgstr "Yıllık"
#: django_q/models.py:181 #: models.py:196
msgid "Cron" msgid "Cron"
msgstr "" msgstr ""
#: django_q/models.py:184 #: models.py:199
msgid "Schedule Type" msgid "Schedule Type"
msgstr "Zamanlama Tipi" msgstr "Zamanlama Tipi"
#: django_q/models.py:187 #: models.py:202
msgid "Number of minutes for the Minutes type" msgid "Number of minutes for the Minutes type"
msgstr "Dakika tipine göre dakika sayısı" msgstr "Dakika tipine göre dakika sayısı"
#: django_q/models.py:190 #: models.py:205
msgid "Repeats" msgid "Repeats"
msgstr "Tekrar eder" msgstr "Tekrar eder"
#: django_q/models.py:190 #: models.py:205
msgid "n = n times, -1 = forever" msgid "n = n times, -1 = forever"
msgstr "n = n kere, -1 = sonsuza kadar" msgstr "n = n kere, -1 = sonsuza kadar"
#: django_q/models.py:193 #: models.py:208
msgid "Next Run" msgid "Next Run"
msgstr "Bir dahaki çalışma tarihi" msgstr "Bir dahaki çalışma tarihi"
#: django_q/models.py:200 #: models.py:215
msgid "Cron expression" msgid "Cron expression"
msgstr "" msgstr ""
#: django_q/models.py:227 #: models.py:224
msgid "Name of kwarg to pass intended schedule date"
msgstr "Geçilecek kwarg'ın adı öngörülen program tarihi"
#: models.py:299
msgid "Scheduled task" msgid "Scheduled task"
msgstr "Zamanlanmış iş" msgstr "Zamanlanmış iş"
#: django_q/models.py:228 #: models.py:300
msgid "Scheduled tasks" msgid "Scheduled tasks"
msgstr "Zamanlanmış işler" msgstr "Zamanlanmış işler"
#: django_q/models.py:251 #: models.py:326
msgid "Queued task" msgid "Queued task"
msgstr "Sıraya alınmış iş" msgstr "Sıraya alınmış iş"
#: django_q/models.py:252 #: models.py:327
msgid "Queued tasks" msgid "Queued tasks"
msgstr "Sıraya alınmış işler" msgstr "Sıraya alınmış işler"
#: django_q/monitor.py:54 django_q/monitor.py:322 #: monitor.py:64 monitor.py:348
msgid "Host" msgid "Host"
msgstr "" msgstr ""
#: django_q/monitor.py:58 django_q/monitor.py:326 django_q/monitor.py:433 #: monitor.py:68 monitor.py:352 monitor.py:459
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: django_q/monitor.py:62 #: monitor.py:72
msgid "State" msgid "State"
msgstr "Durum" msgstr "Durum"
#: django_q/monitor.py:66 #: monitor.py:76
msgid "Pool" msgid "Pool"
msgstr "Havuz" msgstr "Havuz"
#: django_q/monitor.py:70 #: monitor.py:80
msgid "TQ" msgid "TQ"
msgstr "" msgstr ""
#: django_q/monitor.py:74 #: monitor.py:84
msgid "RQ" msgid "RQ"
msgstr "" msgstr ""
#: django_q/monitor.py:78 #: monitor.py:88
msgid "RC" msgid "RC"
msgstr "" msgstr ""
#: django_q/monitor.py:82 #: monitor.py:92
msgid "Up" msgid "Up"
msgstr "" msgstr ""
#: django_q/monitor.py:162 django_q/monitor.py:266 #: monitor.py:172 monitor.py:286
msgid "Queued" msgid "Queued"
msgstr "Sıraya alınmış" msgstr "Sıraya alınmış"
#: django_q/monitor.py:170 #: monitor.py:180
msgid "Success" msgid "Success"
msgstr "Başarılı olanlar" msgstr "Başarılı olanlar"
#: django_q/monitor.py:180 django_q/monitor.py:274 #: monitor.py:190 monitor.py:294
msgid "Failures" msgid "Failures"
msgstr "Başarısız olanlar" msgstr "Başarısız olanlar"
#: django_q/monitor.py:191 #: monitor.py:201 monitor.py:498
msgid "[Press q to quit]" msgid "[Press q to quit]"
msgstr "[Çıkmak için q'ya basın]" msgstr "[Çıkmak için q'ya basın]"
#: django_q/monitor.py:210 #: monitor.py:227
msgid "day" msgid "day"
msgstr "gün" msgstr "gün"
#: django_q/monitor.py:231 #: monitor.py:248
msgid "second" msgid "second"
msgstr "saniye" msgstr "saniye"
#: django_q/monitor.py:234 #: monitor.py:251
msgid "minute" msgid "minute"
msgstr "dakika" msgstr "dakika"
#: django_q/monitor.py:237 #: monitor.py:254
msgid "hour" msgid "hour"
msgstr "saat" msgstr "saat"
#: django_q/monitor.py:247 #: monitor.py:263
msgid "" #, python-format
"-- {Conf.PREFIX.capitalize()} { \".\".join(str(v) for v in VERSION)} on " msgid "-- %(prefix)s %(version)s on %(info)s --"
"{broker.info()} --" msgstr "-- %(prefix)s %(version)s üzerinde %(info)s --"
msgstr ""
#: django_q/monitor.py:253 #: monitor.py:273
msgid "Clusters" msgid "Clusters"
msgstr "" msgstr ""
#: django_q/monitor.py:257 #: monitor.py:277
msgid "Workers" msgid "Workers"
msgstr "" msgstr ""
#: django_q/monitor.py:261 #: monitor.py:281
msgid "Restarts" msgid "Restarts"
msgstr "Yeniden çalıştırmalar" msgstr "Yeniden çalıştırmalar"
#: django_q/monitor.py:270 #: monitor.py:290
msgid "Successes" msgid "Successes"
msgstr "Başarılı olanlar" msgstr "Başarılı olanlar"
#: django_q/monitor.py:279 #: monitor.py:299
msgid "Schedules" msgid "Schedules"
msgstr "Zamanlanmışlar" msgstr "Zamanlanmışlar"
#: django_q/monitor.py:283 #: monitor.py:303
#, python-brace-format #, python-format
msgid "Tasks/{per}" msgid "Tasks/%(per)s"
msgstr "İş/{per}" msgstr "İş/%(per)s"
#: django_q/monitor.py:287 #: monitor.py:307
msgid "Avg time" msgid "Avg time"
msgstr "Ortalama süre" msgstr "Ortalama süre"
#: django_q/monitor.py:331 #: monitor.py:357
msgid "Available (%)" msgid "Available (%)"
msgstr "Müsait (%) " msgstr "Müsait (%) "
#: django_q/monitor.py:337 #: monitor.py:363
msgid "Available (MB)" msgid "Available (MB)"
msgstr "Müsait (MB)" msgstr "Müsait (MB)"
#: django_q/monitor.py:342 #: monitor.py:368
msgid "Total (MB)" msgid "Total (MB)"
msgstr "Toplam (MB)" msgstr "Toplam (MB)"
#: django_q/monitor.py:347 #: monitor.py:373
msgid "Sentinel (MB)" msgid "Sentinel (MB)"
msgstr "" msgstr ""
#: django_q/monitor.py:353 #: monitor.py:379
msgid "Monitor (MB)" msgid "Monitor (MB)"
msgstr "İzleme (MB)" msgstr "İzleme (MB)"
#: django_q/monitor.py:359 #: monitor.py:385
msgid "Workers (MB)" msgid "Workers (MB)"
msgstr "" msgstr ""
#: django_q/monitor.py:461 #: monitor.py:487
msgid "Available lowest (%): {} ({})" #, python-format
msgstr "Mevcut en düşük (%): {} ({})" msgid "Available lowest (): %(memory_percent)s ((at)s)"
msgstr "Mevcut en düşük (): %(memory_percent)s ((at)s)"
#: django_q/signals.py:22 #: monitor.py:509
#, python-brace-format msgid "No clusters appear to be running."
msgid "malformed return hook '{instance.hook}' for [{instance.name}]" msgstr "Hiçbir küme çalışıyor görünmüyor."
#: signals.py:22
#, python-format
msgid "malformed return hook '%(hook)s' for [%(name)s]"
msgstr "" msgstr ""
#: django_q/signals.py:30 #: signals.py:30
msgid "" #, python-format
"return hook {instance.hook} failed on [{instance.name}] because {str(e)}" msgid "return hook %(hook)s failed on [%(name)s] because %(error)s"
msgstr "" msgstr ""
#, python-format
#~ msgid ""
#~ "Could not process '%(func_name)s'. Check the location of the function and "
#~ "the args/kwargs."
#~ msgstr ""
#~ "%(func_name)s' işlenemedi. İşlevin konumunu ve args/kwargs öğelerini "
#~ "kontrol edin."
+15
View File
@@ -1,3 +1,5 @@
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 _
@@ -16,8 +18,21 @@ class Command(BaseCommand):
default=False, default=False,
help="Run once and then stop.", help="Run once and then stop.",
) )
parser.add_argument(
"-n",
"--name",
dest="cluster_name",
default=None,
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.",
)
def handle(self, *args, **options): def handle(self, *args, **options):
# Set alternative cluster_name before creating the cluster (cluster_name is broker's queue_name, too)
cluster_name = options.get("cluster_name")
if cluster_name:
os.environ["Q_CLUSTER_NAME"] = cluster_name
q = Cluster() q = Cluster()
q.start() q.start()
if options.get("run_once", False): if options.get("run_once", False):
+1 -1
View File
@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
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 import get_ids, info from django_q.monitor_terminal import get_ids, info
class Command(BaseCommand): class Command(BaseCommand):
+1 -1
View File
@@ -1,7 +1,7 @@
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.monitor import memory from django_q.monitor_terminal import memory
class Command(BaseCommand): class Command(BaseCommand):
+1 -1
View File
@@ -1,7 +1,7 @@
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.monitor import monitor from django_q.monitor_terminal import monitor
class Command(BaseCommand): class Command(BaseCommand):
+118 -38
View File
@@ -4,62 +4,142 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = []
dependencies = [
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Schedule', name="Schedule",
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), (
('func', models.CharField(max_length=256, help_text='e.g. module.tasks.function')), "id",
('hook', models.CharField(null=True, blank=True, max_length=256, help_text='e.g. module.tasks.result_function')), models.AutoField(
('args', models.CharField(null=True, blank=True, max_length=256, help_text="e.g. 1, 2, 'John'")), verbose_name="ID",
('kwargs', models.CharField(null=True, blank=True, max_length=256, help_text="e.g. x=1, y=2, name='John'")), auto_created=True,
('schedule_type', models.CharField(verbose_name='Schedule Type', choices=[('O', 'Once'), ('H', 'Hourly'), ('D', 'Daily'), ('W', 'Weekly'), ('M', 'Monthly'), ('Q', 'Quarterly'), ('Y', 'Yearly')], default='O', max_length=1)), serialize=False,
('repeats', models.SmallIntegerField(verbose_name='Repeats', default=-1, help_text='n = n times, -1 = forever')), primary_key=True,
('next_run', models.DateTimeField(verbose_name='Next Run', default=django.utils.timezone.now, null=True)), ),
('task', models.CharField(editable=False, null=True, max_length=100)), ),
(
"func",
models.CharField(
max_length=256, help_text="e.g. module.tasks.function"
),
),
(
"hook",
models.CharField(
null=True,
blank=True,
max_length=256,
help_text="e.g. module.tasks.result_function",
),
),
(
"args",
models.CharField(
null=True,
blank=True,
max_length=256,
help_text="e.g. 1, 2, 'John'",
),
),
(
"kwargs",
models.CharField(
null=True,
blank=True,
max_length=256,
help_text="e.g. x=1, y=2, name='John'",
),
),
(
"schedule_type",
models.CharField(
verbose_name="Schedule Type",
choices=[
("O", "Once"),
("H", "Hourly"),
("D", "Daily"),
("W", "Weekly"),
("M", "Monthly"),
("Q", "Quarterly"),
("Y", "Yearly"),
],
default="O",
max_length=1,
),
),
(
"repeats",
models.SmallIntegerField(
verbose_name="Repeats",
default=-1,
help_text="n = n times, -1 = forever",
),
),
(
"next_run",
models.DateTimeField(
verbose_name="Next Run",
default=django.utils.timezone.now,
null=True,
),
),
("task", models.CharField(editable=False, null=True, max_length=100)),
], ],
options={ options={
'verbose_name': 'Scheduled task', "verbose_name": "Scheduled task",
'ordering': ['next_run'], "ordering": ["next_run"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Task', name="Task",
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), (
('name', models.CharField(editable=False, max_length=100)), "id",
('func', models.CharField(max_length=256)), models.AutoField(
('hook', models.CharField(null=True, max_length=256)), verbose_name="ID",
('args', picklefield.fields.PickledObjectField(editable=False, null=True)), auto_created=True,
('kwargs', picklefield.fields.PickledObjectField(editable=False, null=True)), serialize=False,
('result', picklefield.fields.PickledObjectField(editable=False, null=True)), primary_key=True,
('started', models.DateTimeField(editable=False)), ),
('stopped', models.DateTimeField(editable=False)), ),
('success', models.BooleanField(editable=False, default=True)), ("name", models.CharField(editable=False, max_length=100)),
("func", models.CharField(max_length=256)),
("hook", models.CharField(null=True, max_length=256)),
(
"args",
picklefield.fields.PickledObjectField(editable=False, null=True),
),
(
"kwargs",
picklefield.fields.PickledObjectField(editable=False, null=True),
),
(
"result",
picklefield.fields.PickledObjectField(editable=False, null=True),
),
("started", models.DateTimeField(editable=False)),
("stopped", models.DateTimeField(editable=False)),
("success", models.BooleanField(editable=False, default=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Failure', name="Failure",
fields=[ fields=[],
],
options={ options={
'verbose_name': 'Failed task', "verbose_name": "Failed task",
'proxy': True, "proxy": True,
}, },
bases=('django_q.task',), bases=("django_q.task",),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Success', name="Success",
fields=[ fields=[],
],
options={ options={
'verbose_name': 'Successful task', "verbose_name": "Successful task",
'proxy': True, "proxy": True,
}, },
bases=('django_q.task',), bases=("django_q.task",),
), ),
] ]
+11 -8
View File
@@ -2,20 +2,23 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('django_q', '0001_initial'), ("django_q", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='args', name="args",
field=models.TextField(help_text="e.g. 1, 2, 'John'", blank=True, null=True), field=models.TextField(
help_text="e.g. 1, 2, 'John'", blank=True, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='kwargs', name="kwargs",
field=models.TextField(help_text="e.g. x=1, y=2, name='John'", blank=True, null=True), field=models.TextField(
help_text="e.g. x=1, y=2, name='John'", blank=True, null=True
),
), ),
] ]
+24 -13
View File
@@ -2,31 +2,42 @@ 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"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='failure', name="failure",
options={'verbose_name_plural': 'Failed tasks', 'verbose_name': 'Failed task'}, options={
"verbose_name_plural": "Failed tasks",
"verbose_name": "Failed task",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='schedule', name="schedule",
options={'verbose_name_plural': 'Scheduled tasks', 'ordering': ['next_run'], 'verbose_name': 'Scheduled task'}, options={
"verbose_name_plural": "Scheduled tasks",
"ordering": ["next_run"],
"verbose_name": "Scheduled task",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='success', name="success",
options={'verbose_name_plural': 'Successful tasks', 'verbose_name': 'Successful task'}, options={
"verbose_name_plural": "Successful tasks",
"verbose_name": "Successful task",
},
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='task', model_name="task",
name='id', name="id",
), ),
migrations.AddField( migrations.AddField(
model_name='task', model_name="task",
name='id', name="id",
field=models.CharField(max_length=32, primary_key=True, editable=False, serialize=False), field=models.CharField(
max_length=32, primary_key=True, editable=False, serialize=False
),
), ),
] ]
+16 -9
View File
@@ -1,23 +1,30 @@
from django.db import migrations, models 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"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='failure', name="failure",
options={'verbose_name_plural': 'Failed tasks', 'verbose_name': 'Failed task', 'ordering': ['-stopped']}, options={
"verbose_name_plural": "Failed tasks",
"verbose_name": "Failed task",
"ordering": ["-stopped"],
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='success', name="success",
options={'verbose_name_plural': 'Successful tasks', 'verbose_name': 'Successful task', 'ordering': ['-stopped']}, options={
"verbose_name_plural": "Successful tasks",
"verbose_name": "Successful task",
"ordering": ["-stopped"],
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='task', name="task",
options={'ordering': ['-stopped']}, options={"ordering": ["-stopped"]},
), ),
] ]
@@ -2,20 +2,19 @@ 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"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='schedule', model_name="schedule",
name='name', name="name",
field=models.CharField(max_length=100, null=True), field=models.CharField(max_length=100, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='task', model_name="task",
name='group', name="group",
field=models.CharField(max_length=100, null=True, editable=False), field=models.CharField(max_length=100, null=True, editable=False),
), ),
] ]
+25 -8
View File
@@ -2,20 +2,37 @@ 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"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='schedule', model_name="schedule",
name='minutes', name="minutes",
field=models.PositiveSmallIntegerField(help_text='Number of minutes for the Minutes type', blank=True, null=True), field=models.PositiveSmallIntegerField(
help_text="Number of minutes for the Minutes type",
blank=True,
null=True,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='schedule_type', name="schedule_type",
field=models.CharField(max_length=1, choices=[('O', 'Once'), ('I', 'Minutes'), ('H', 'Hourly'), ('D', 'Daily'), ('W', 'Weekly'), ('M', 'Monthly'), ('Q', 'Quarterly'), ('Y', 'Yearly')], default='O', verbose_name='Schedule Type'), field=models.CharField(
max_length=1,
choices=[
("O", "Once"),
("I", "Minutes"),
("H", "Hourly"),
("D", "Daily"),
("W", "Weekly"),
("M", "Monthly"),
("Q", "Quarterly"),
("Y", "Yearly"),
],
default="O",
verbose_name="Schedule Type",
),
), ),
] ]
+16 -9
View File
@@ -2,23 +2,30 @@ 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"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='OrmQ', name="OrmQ",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)), (
('key', models.CharField(max_length=100)), "id",
('payload', models.TextField()), models.AutoField(
('lock', models.DateTimeField(null=True)), primary_key=True,
auto_created=True,
verbose_name="ID",
serialize=False,
),
),
("key", models.CharField(max_length=100)),
("payload", models.TextField()),
("lock", models.DateTimeField(null=True)),
], ],
options={ options={
'verbose_name_plural': 'Queued tasks', "verbose_name_plural": "Queued tasks",
'verbose_name': 'Queued task', "verbose_name": "Queued task",
}, },
), ),
] ]
@@ -2,15 +2,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('django_q', '0007_ormq'), ("django_q", "0007_ormq"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='name', name="name",
field=models.CharField(blank=True, max_length=100, null=True), field=models.CharField(blank=True, max_length=100, null=True),
), ),
] ]
@@ -2,15 +2,18 @@ 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"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='repeats', name="repeats",
field=models.IntegerField(default=-1, help_text='n = n times, -1 = forever', verbose_name='Repeats'), field=models.IntegerField(
default=-1,
help_text="n = n times, -1 = forever",
verbose_name="Repeats",
),
), ),
] ]
+16 -11
View File
@@ -3,25 +3,30 @@ 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"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='task', model_name="task",
name='args', name="args",
field=picklefield.fields.PickledObjectField(editable=False, null=True, protocol=-1), field=picklefield.fields.PickledObjectField(
editable=False, null=True, protocol=-1
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='task', model_name="task",
name='kwargs', name="kwargs",
field=picklefield.fields.PickledObjectField(editable=False, null=True, protocol=-1), field=picklefield.fields.PickledObjectField(
editable=False, null=True, protocol=-1
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='task', model_name="task",
name='result', name="result",
field=picklefield.fields.PickledObjectField(editable=False, null=True, protocol=-1), field=picklefield.fields.PickledObjectField(
editable=False, null=True, protocol=-1
),
), ),
] ]
+24 -8
View File
@@ -4,20 +4,36 @@ 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"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='schedule', model_name="schedule",
name='cron', name="cron",
field=models.CharField(blank=True, help_text='Cron expression', max_length=100, null=True), field=models.CharField(
blank=True, help_text="Cron expression", max_length=100, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='schedule_type', name="schedule_type",
field=models.CharField(choices=[('O', 'Once'), ('I', 'Minutes'), ('H', 'Hourly'), ('D', 'Daily'), ('W', 'Weekly'), ('M', 'Monthly'), ('Q', 'Quarterly'), ('Y', 'Yearly'), ('C', 'Cron')], default='O', max_length=1, verbose_name='Schedule Type'), field=models.CharField(
choices=[
("O", "Once"),
("I", "Minutes"),
("H", "Hourly"),
("D", "Daily"),
("W", "Weekly"),
("M", "Monthly"),
("Q", "Quarterly"),
("Y", "Yearly"),
("C", "Cron"),
],
default="O",
max_length=1,
verbose_name="Schedule Type",
),
), ),
] ]
+10 -5
View File
@@ -6,15 +6,20 @@ 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"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='schedule', model_name="schedule",
name='cron', name="cron",
field=models.CharField(blank=True, help_text='Cron expression', max_length=100, null=True, validators=[django_q.models.validate_cron]), field=models.CharField(
blank=True,
help_text="Cron expression",
max_length=100,
null=True,
validators=[django_q.models.validate_cron],
),
), ),
] ]
@@ -4,15 +4,14 @@ 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"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='task', model_name="task",
name='attempt_count', name="attempt_count",
field=models.IntegerField(default=0), field=models.IntegerField(default=0),
), ),
] ]
+3 -4
View File
@@ -4,15 +4,14 @@ 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"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='schedule', model_name="schedule",
name='cluster', name="cluster",
field=models.CharField(blank=True, default=None, max_length=100, null=True), field=models.CharField(blank=True, default=None, max_length=100, null=True),
), ),
] ]
@@ -0,0 +1,34 @@
# Generated by Django 4.1.2 on 2022-11-10 01:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_q", "0014_schedule_cluster"),
]
operations = [
migrations.AlterField(
model_name="schedule",
name="schedule_type",
field=models.CharField(
choices=[
("O", "Once"),
("I", "Minutes"),
("H", "Hourly"),
("D", "Daily"),
("W", "Weekly"),
("BW", "Biweekly"),
("M", "Monthly"),
("BM", "Bimonthly"),
("Q", "Quarterly"),
("Y", "Yearly"),
("C", "Cron"),
],
default="O",
max_length=2,
verbose_name="Schedule Type",
),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.1.2 on 2023-01-15 22:34
from django.db import migrations, models
import django_q.models
class Migration(migrations.Migration):
dependencies = [
("django_q", "0015_alter_schedule_schedule_type"),
]
operations = [
migrations.AddField(
model_name="schedule",
name="intended_date_kwarg",
field=models.CharField(
blank=True,
help_text="Name of kwarg to pass intended schedule date",
max_length=100,
null=True,
validators=[django_q.models.validate_kwarg],
),
),
]
@@ -0,0 +1,42 @@
# Generated by Django 4.1.5 on 2023-03-07 12:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_q", "0016_schedule_intended_date_kwarg"),
]
operations = [
migrations.AddField(
model_name="task",
name="cluster",
field=models.CharField(blank=True, default=None, max_length=100, null=True),
),
migrations.AlterField(
model_name="ormq",
name="key",
field=models.CharField(
help_text="Name of the target cluster", max_length=100
),
),
migrations.AlterField(
model_name="ormq",
name="lock",
field=models.DateTimeField(
help_text="Prevent any cluster from pulling until", null=True
),
),
migrations.AlterField(
model_name="schedule",
name="cluster",
field=models.CharField(
blank=True,
default=None,
help_text="Name of the target cluster",
max_length=100,
null=True,
),
),
]
@@ -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",
),
),
]
+110 -10
View File
@@ -1,10 +1,15 @@
from datetime import datetime, timedelta
from keyword import iskeyword
# 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.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 _
@@ -15,6 +20,9 @@ from picklefield.fields import dbsafe_decode
# Local # Local
from django_q.conf import croniter from django_q.conf import croniter
from django_q.signing import SignedPackage from django_q.signing import SignedPackage
from django_q.utils import add_months, add_years, localtime
from .utils import get_func_repr
class Task(models.Model): class Task(models.Model):
@@ -26,6 +34,7 @@ class Task(models.Model):
kwargs = PickledObjectField(null=True, protocol=-1) kwargs = PickledObjectField(null=True, protocol=-1)
result = PickledObjectField(null=True, protocol=-1) result = PickledObjectField(null=True, protocol=-1)
group = models.CharField(max_length=100, editable=False, null=True) group = models.CharField(max_length=100, editable=False, null=True)
cluster = models.CharField(max_length=100, default=None, null=True, blank=True)
started = models.DateTimeField(editable=False) started = models.DateTimeField(editable=False)
stopped = models.DateTimeField(editable=False) stopped = models.DateTimeField(editable=False)
success = models.BooleanField(default=True, editable=False) success = models.BooleanField(default=True, editable=False)
@@ -103,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):
@@ -147,6 +163,10 @@ def validate_cron(value):
raise ValidationError(e) raise ValidationError(e)
def validate_kwarg(value):
return value.isidentifier() and not iskeyword(value)
class Schedule(models.Model): class Schedule(models.Model):
name = models.CharField(max_length=100, null=True, blank=True) name = models.CharField(max_length=100, null=True, blank=True)
func = models.CharField(max_length=256, help_text="e.g. module.tasks.function") func = models.CharField(max_length=256, help_text="e.g. module.tasks.function")
@@ -165,7 +185,9 @@ class Schedule(models.Model):
HOURLY = "H" HOURLY = "H"
DAILY = "D" DAILY = "D"
WEEKLY = "W" WEEKLY = "W"
BIWEEKLY = "BW"
MONTHLY = "M" MONTHLY = "M"
BIMONTHLY = "BM"
QUARTERLY = "Q" QUARTERLY = "Q"
YEARLY = "Y" YEARLY = "Y"
CRON = "C" CRON = "C"
@@ -175,13 +197,15 @@ class Schedule(models.Model):
(HOURLY, _("Hourly")), (HOURLY, _("Hourly")),
(DAILY, _("Daily")), (DAILY, _("Daily")),
(WEEKLY, _("Weekly")), (WEEKLY, _("Weekly")),
(BIWEEKLY, _("Biweekly")),
(MONTHLY, _("Monthly")), (MONTHLY, _("Monthly")),
(BIMONTHLY, _("Bimonthly")),
(QUARTERLY, _("Quarterly")), (QUARTERLY, _("Quarterly")),
(YEARLY, _("Yearly")), (YEARLY, _("Yearly")),
(CRON, _("Cron")), (CRON, _("Cron")),
) )
schedule_type = models.CharField( schedule_type = models.CharField(
max_length=1, choices=TYPE, default=TYPE[0][0], verbose_name=_("Schedule Type") max_length=2, choices=TYPE, default=TYPE[0][0], verbose_name=_("Schedule Type")
) )
minutes = models.PositiveSmallIntegerField( minutes = models.PositiveSmallIntegerField(
null=True, blank=True, help_text=_("Number of minutes for the Minutes type") null=True, blank=True, help_text=_("Number of minutes for the Minutes type")
@@ -200,7 +224,68 @@ class Schedule(models.Model):
help_text=_("Cron expression"), help_text=_("Cron expression"),
) )
task = models.CharField(max_length=100, null=True, editable=False) task = models.CharField(max_length=100, null=True, editable=False)
cluster = models.CharField(max_length=100, default=None, null=True, blank=True) cluster = models.CharField(
max_length=100,
default=None,
null=True,
blank=True,
help_text=_("Name of the target cluster"),
)
intended_date_kwarg = models.CharField(
max_length=100,
null=True,
blank=True,
validators=[validate_kwarg],
help_text=_("Name of kwarg to pass intended schedule date"),
)
def calculate_next_run(self, next_run=None):
# next run is always in UTC
next_run = next_run or self.next_run
if self.schedule_type == self.CRON:
if not croniter:
raise ImportError(
_("Please install croniter to enable cron expressions")
)
return croniter(self.cron, localtime()).get_next(datetime)
if self.schedule_type == self.MINUTES:
add = timedelta(minutes=(self.minutes or 1))
elif self.schedule_type == self.HOURLY:
add = timedelta(hours=1)
elif self.schedule_type == self.DAILY:
add = timedelta(days=1)
elif self.schedule_type == self.WEEKLY:
add = timedelta(weeks=1)
elif self.schedule_type == self.BIWEEKLY:
add = timedelta(weeks=2)
elif self.schedule_type == self.MONTHLY:
add = timedelta(days=(add_months(next_run, 1) - next_run).days)
elif self.schedule_type == self.BIMONTHLY:
add = timedelta(days=(add_months(next_run, 2) - next_run).days)
elif self.schedule_type == self.QUARTERLY:
add = timedelta(days=(add_months(next_run, 3) - next_run).days)
elif self.schedule_type == self.YEARLY:
add = timedelta(days=(add_years(next_run, 1) - next_run).days)
# add normal timedelta, we will correct this later based on timezone
next_run += add
# DST differencers don't matter with minutes, hourly or yearly, so skip those
if self.schedule_type not in [self.MINUTES, self.HOURLY, self.YEARLY]:
# Get localtimes and then remove the tzinfo, so we can get the actual difference
current_next_run = localtime(next_run - add).replace(tzinfo=None)
new_next_run = localtime(next_run).replace(tzinfo=None)
# get the difference between them, this should be (-)1 or (-)0.5 hour
# based on DST active or not
extra_diff = (new_next_run - current_next_run) - add
# subtract difference
next_run -= extra_diff
return next_run
def success(self): def success(self):
if self.task and Task.objects.filter(id=self.task): if self.task and Task.objects.filter(id=self.task):
@@ -224,7 +309,6 @@ class Schedule(models.Model):
last_run.allow_tags = True last_run.allow_tags = True
last_run.short_description = _("last_run") last_run.short_description = _("last_run")
class Meta: class Meta:
app_label = "django_q" app_label = "django_q"
verbose_name = _("Scheduled task") verbose_name = _("Scheduled task")
@@ -233,24 +317,40 @@ class Schedule(models.Model):
class OrmQ(models.Model): class OrmQ(models.Model):
key = models.CharField(max_length=100) key = models.CharField(max_length=100, help_text=_("Name of the target cluster"))
payload = models.TextField() payload = models.TextField()
lock = models.DateTimeField(null=True) lock = models.DateTimeField(
null=True, help_text=_("Prevent any cluster from pulling until")
)
@cached_property
def task(self): def task(self):
return SignedPackage.loads(self.payload) try:
return SignedPackage.loads(self.payload)
except Exception as e:
return {"id": "*" + e.__class__.__name__}
def func(self): def func(self):
return self.task()["func"] return get_func_repr(self.task.get("func"))
def task_id(self): def task_id(self):
return self.task()["id"] return self.task.get("id")
def name(self): def name(self):
return self.task()["name"] return self.task.get("name")
def group(self): def group(self):
return self.task().get("group") return self.task.get("group")
def args(self):
return self.task.get("args")
def kwargs(self):
return self.task.get("kwargs")
def q_options(self):
exclude = {"id", "name", "group", "func", "args", "kwargs"}
return {k: v for k, v in self.task.items() if k not in exclude}
class Meta: class Meta:
app_label = "django_q" app_label = "django_q"
+188 -484
View File
@@ -1,500 +1,204 @@
from datetime import timedelta from multiprocessing.process import current_process
from multiprocessing.queues import Queue
# django from django import core, db
from django.db import connection from django.apps.registry import apps
from django.db.models import F, Sum from django.utils.translation import gettext_lazy as _
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: try:
import psutil apps.check_apps_ready()
except ImportError: except core.exceptions.AppRegistryNotReady:
psutil = None 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
# optional
try: try:
from blessed import Terminal import setproctitle
except ImportError: except ModuleNotFoundError:
pass setproctitle = 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(result_queue: Queue, broker: Broker = None):
"""
def monitor(run_once=False, broker=None): Gets finished tasks from the result queue and saves them to Django
:type broker: brokers.Broker
:type result_queue: multiprocessing.Queue
"""
if not broker: if not broker:
broker = get_broker() broker = get_broker()
try: proc_name = current_process().name
term = Terminal() if setproctitle:
except: setproctitle.setproctitle(f"qcluster {proc_name} monitor")
print(BLESSED_INSTALL_MESSAGE) logger.info(
return _("%(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:
save_task(task, broker)
# acknowledge result
ack_id = task.pop("ack_id", False)
if ack_id and (task["success"] or task.get("ack_failure", False)):
broker.acknowledge(ack_id)
# signal execution done
post_execute.send(sender="django_q", task=task)
# 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})
broker.ping()
with term.fullscreen(), term.hidden_cursor(), term.cbreak(): def save_task(task, broker: Broker):
val = None """
start_width = int(term.width / 8) Saves the task package to Django or the cache
while val not in ( :param task: the task package
"q", :type broker: brokers.Broker
"Q", """
# 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()
try:
filters = {}
if (
Conf.SAVE_LIMIT_PER
and Conf.SAVE_LIMIT_PER in {"group", "name", "func"}
and Conf.SAVE_LIMIT_PER in task
): ):
col_width = int(term.width / 8) value = task[Conf.SAVE_LIMIT_PER]
# In case of resize if Conf.SAVE_LIMIT_PER == "func":
if col_width != start_width: value = get_func_repr(value)
print(term.clear()) filters[Conf.SAVE_LIMIT_PER] = value
start_width = col_width
print( with db.transaction.atomic(using=db.router.db_for_write(Success)):
term.move(0, 0) list(Success.objects.filter(**filters).select_for_update())
+ term.black_on_green(term.center(_("Host"), width=col_width - 1)) if (
task["success"]
and 0 < Conf.SAVE_LIMIT <= Success.objects.filter(**filters).count()
):
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,
) )
print(
term.move(0, 1 * col_width) if Conf.MAX_ATTEMPTS > 0 and task_obj.attempt_count >= Conf.MAX_ATTEMPTS:
+ term.black_on_green(term.center(_("Id"), width=col_width - 1)) broker.acknowledge(task["ack_id"])
)
print( except Exception:
term.move(0, 2 * col_width) logger.exception("Could not save task result")
+ 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): def save_cached(task, broker: Broker):
if not broker: task_key = f'{broker.list_key}:{task["id"]}'
broker = get_broker() timeout = task["cached"]
if timeout is True:
timeout = None
try: try:
term = Terminal() group = task.get("group", None)
except: iter_count = task.get("iter_count", 0)
print(BLESSED_INSTALL_MESSAGE) # if it's a group append to the group list
return if group:
group_key = f"{broker.list_key}:{group}:keys"
broker.ping() group_list = broker.cache.get(group_key) or []
stat = Stat.get_all(broker=broker) # if it's an iter group, check if we are ready
# general stats if iter_count and len(group_list) == iter_count - 1:
clusters = len(stat) group_args = f"{broker.list_key}:{group}:args"
workers = 0 # collate the results into a Task result
reincarnations = 0 results = [
for cluster in stat: SignedPackage.loads(broker.cache.get(k))["result"]
workers += len(cluster.workers) for k in group_list
reincarnations += cluster.reincarnations ]
# calculate tasks pm and avg exec time results.append(task["result"])
tasks_per = 0 task["result"] = results
per = _("day") task["id"] = group
exec_time = 0 task["args"] = SignedPackage.loads(broker.cache.get(group_args))
last_tasks = models.Success.objects.filter( task.pop("iter_count", None)
stopped__gte=timezone.now() - timedelta(hours=24) task.pop("group", None)
) if task.get("iter_cached", None):
tasks_per_day = last_tasks.count() task["cached"] = task.pop("iter_cached", None)
if tasks_per_day > 0: save_cached(task, broker=broker)
# average execution time over the last 24 hours else:
if connection.vendor != "sqlite": save_task(task, broker)
exec_time = last_tasks.aggregate( broker.cache.delete_many(group_list)
time_taken=Sum(F("stopped") - F("started")) broker.cache.delete_many([group_key, group_args])
) return
exec_time = exec_time["time_taken"].total_seconds() / tasks_per_day # save the group list
else: group_list.append(task_key)
# can't sum timedeltas on sqlite broker.cache.set(group_key, group_list, timeout)
for t in last_tasks: # async_task next in a chain
exec_time += t.time_taken() if task.get("chain", None):
exec_time = exec_time / tasks_per_day async_chain(
# tasks per second/minute/hour/day in the last 24 hours task["chain"],
if tasks_per_day > 24 * 60 * 60: group=group,
tasks_per = tasks_per_day / (24 * 60 * 60) cached=task["cached"],
per = _("second") sync=task["sync"],
elif tasks_per_day > 24 * 60: broker=broker,
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(
_(
f'-- {Conf.PREFIX.capitalize()} { ".".join(str(v) for v in VERSION)} on {broker.info()} --'
) )
) # save the task
) broker.cache.set(task_key, SignedPackage.dumps(task), timeout)
) except Exception:
print( logger.exception("Could not save task result")
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(_(f"Tasks/{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:
term = Terminal()
except:
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 (%): {} ({})").format(
str(MEMORY_AVAILABLE_LOWEST_PERCENTAGE),
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
+508
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
+71
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})
+3 -1
View File
@@ -1,6 +1,8 @@
""" """
The code is derived from https://github.com/althonos/pronto/commit/3384010dfb4fc7c66a219f59276adef3288a886b The code is derived from
https://github.com/althonos/pronto/commit/3384010dfb4fc7c66a219f59276adef3288a886b
""" """
import multiprocessing import multiprocessing
import multiprocessing.queues import multiprocessing.queues
import sys import sys
+140
View File
@@ -0,0 +1,140 @@
import ast
from multiprocessing.process import current_process
from django import core, db
from django.apps.registry import apps
from django.utils import timezone
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.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=[])
)
with db.transaction.atomic(using=db.router.db_for_write(Schedule)):
for s in (
Schedule.objects.select_for_update()
.exclude(repeats=0)
.filter(next_run__lt=timezone.now())
.filter(Q_default | db.models.Q(cluster=Conf.CLUSTER_NAME))
):
args = ()
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", {})
if s.intended_date_kwarg:
kwargs[s.intended_date_kwarg] = s.next_run.isoformat()
if s.hook:
q_options["hook"] = s.hook
# set up the next run time
if s.schedule_type != s.ONCE:
next_run = s.next_run
while True:
next_run = s.calculate_next_run(next_run)
if Conf.CATCH_UP or next_run > localtime():
break
s.next_run = next_run
# 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)
kwargs["q_options"] = q_options
s.task = async_task(s.func, *args, **kwargs)
# log it
if not s.task:
logger.error(
_(
"%(process_name)s failed to create a task from schedule "
"[%(schedule)s]"
)
% {
"process_name": current_process().name,
"schedule": s.name or s.id,
}
)
else:
logger.info(
_(
"%(process_name)s created task %(task_name)s from schedule "
"[%(schedule)s]"
)
% {
"process_name": current_process().name,
"task_name": humanize(s.task),
"schedule": s.name or s.id,
}
)
# default behavior is to delete a ONCE schedule
if s.schedule_type == s.ONCE:
if s.repeats < 0:
s.delete()
continue
# but not if it has a positive repeats
s.repeats = 0
# save the schedule
s.save()
except Exception:
logger.exception("Could not create task from schedule")
+7 -4
View File
@@ -19,19 +19,22 @@ def call_hook(sender, instance, **kwargs):
f = getattr(m, func) f = getattr(m, func)
except (ValueError, ImportError, AttributeError): except (ValueError, ImportError, AttributeError):
logger.error( logger.error(
_(f"malformed return hook '{instance.hook}' for [{instance.name}]") _("malformed return hook '%(hook)s' for [%(name)s]")
% {"hook": instance.hook, "name": instance.name}
) )
return return
try: try:
f(instance) f(instance)
except Exception as e: except Exception as e:
logger.error( logger.error(
_( _("return hook %(hook)s failed on [%(name)s] because %(error)s")
f"return hook {instance.hook} failed on [{instance.name}] because {str(e)}" % {"hook": instance.hook, "name": instance.name, "error": str(e)}
)
) )
# args: proc_name
post_spawn = Signal()
# args: task # args: task
pre_enqueue = Signal() pre_enqueue = Signal()
+1
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
+20 -12
View File
@@ -1,4 +1,5 @@
"""Provides task functionality.""" """Provides task functionality."""
# Standard # Standard
from multiprocessing import Value from multiprocessing import Value
from time import sleep, time from time import sleep, time
@@ -31,6 +32,7 @@ def async_task(func, *args, **kwargs):
"iter_cached", "iter_cached",
"chain", "chain",
"broker", "broker",
"cluster",
"timeout", "timeout",
) )
q_options = keywords.pop("q_options", {}) q_options = keywords.pop("q_options", {})
@@ -52,7 +54,7 @@ def async_task(func, *args, **kwargs):
elif key in keywords: elif key in keywords:
task[key] = keywords.pop(key) task[key] = keywords.pop(key)
# don't serialize the broker # don't serialize the broker
broker = task.pop("broker", get_broker()) broker = task.pop("broker", None) or get_broker(task.get("cluster"))
# overrides # overrides
if "cached" not in task and Conf.CACHED: if "cached" not in task and Conf.CACHED:
task["cached"] = Conf.CACHED task["cached"] = Conf.CACHED
@@ -71,7 +73,7 @@ def async_task(func, *args, **kwargs):
return _sync(pack) return _sync(pack)
# push it # push it
enqueue_id = broker.enqueue(pack) enqueue_id = broker.enqueue(pack)
logger.info(f"Enqueued {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"]
@@ -90,6 +92,7 @@ def schedule(func, *args, **kwargs):
:type next_run: datetime.datetime :type next_run: datetime.datetime
:param cluster: optional cluster name. :param cluster: optional cluster name.
:param cron: optional cron expression :param cron: optional cron expression
:param intended_date_kwarg: optional identifier to pass intended schedule date.
:param kwargs: function keyword arguments. :param kwargs: function keyword arguments.
:return: the schedule object. :return: the schedule object.
:rtype: Schedule :rtype: Schedule
@@ -102,6 +105,7 @@ def schedule(func, *args, **kwargs):
next_run = kwargs.pop("next_run", timezone.now()) next_run = kwargs.pop("next_run", timezone.now())
cron = kwargs.pop("cron", None) cron = kwargs.pop("cron", None)
cluster = kwargs.pop("cluster", None) cluster = kwargs.pop("cluster", None)
intended_date_kwarg = kwargs.pop("intended_date_kwarg", None)
# check for name duplicates instead of am unique constraint # check for name duplicates instead of am unique constraint
if name and Schedule.objects.filter(name=name).exists(): if name and Schedule.objects.filter(name=name).exists():
@@ -120,6 +124,7 @@ def schedule(func, *args, **kwargs):
next_run=next_run, next_run=next_run,
cron=cron, cron=cron,
cluster=cluster, cluster=cluster,
intended_date_kwarg=intended_date_kwarg,
) )
# make sure we trigger validation # make sure we trigger validation
s.full_clean() s.full_clean()
@@ -144,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
@@ -270,6 +275,7 @@ def fetch_cached(task_id, wait=0, broker=None):
hook=task.get("hook"), hook=task.get("hook"),
args=task["args"], args=task["args"],
kwargs=task["kwargs"], kwargs=task["kwargs"],
cluster=task.get("cluster"),
started=task["started"], started=task["started"],
stopped=task["stopped"], stopped=task["stopped"],
result=task["result"], result=task["result"],
@@ -340,6 +346,7 @@ def fetch_group_cached(group_id, failures=True, wait=0, count=None, broker=None)
hook=task.get("hook"), hook=task.get("hook"),
args=task["args"], args=task["args"],
kwargs=task["kwargs"], kwargs=task["kwargs"],
cluster=task.get("cluster"),
started=task["started"], started=task["started"],
stopped=task["stopped"], stopped=task["stopped"],
result=task["result"], result=task["result"],
@@ -563,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
@@ -600,7 +609,8 @@ class Chain:
def result(self, wait=0): def result(self, wait=0):
""" """
return the full list of results from the chain when it finishes. blocks until timeout. return the full list of results from the chain when it finishes. blocks until
timeout.
:param int wait: how many milliseconds to wait for a result :param int wait: how many milliseconds to wait for a result
:return: an unsorted list of results :return: an unsorted list of results
""" """
@@ -611,7 +621,8 @@ class Chain:
def fetch(self, failures=True, wait=0): def fetch(self, failures=True, wait=0):
""" """
get the task result objects from the chain when it finishes. blocks until timeout. get the task result objects from the chain when it finishes. blocks until
timeout.
:param failures: include failed tasks :param failures: include failed tasks
:param int wait: how many milliseconds to wait for a result :param int wait: how many milliseconds to wait for a result
:return: an unsorted list of task objects :return: an unsorted list of task objects
@@ -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,8 @@ class AsyncTask:
def _sync(pack): def _sync(pack):
"""Simulate a package travelling through the cluster.""" """Simulate a package travelling through the cluster."""
from django_q.cluster import monitor, worker from django_q.monitor import monitor
from django_q.worker import worker
task_queue = Queue() task_queue = Queue()
result_queue = Queue() result_queue = Queue()
+2 -4
View File
@@ -1,7 +1,5 @@
import os import os
import django
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -77,7 +75,7 @@ DATABASES = {
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC" TIME_ZONE = "Europe/Amsterdam"
USE_I18N = True USE_I18N = True
@@ -130,5 +128,5 @@ Q_CLUSTER = {
"testing": True, "testing": True,
"log_level": "DEBUG", "log_level": "DEBUG",
"django_redis": "default", "django_redis": "default",
"redis": f"redis://{REDIS_HOST}:6379/0" "redis": f"redis://{REDIS_HOST}:6379/0",
} }
+4
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()}")
+8 -1
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
+1 -2
View File
@@ -2,12 +2,11 @@ import os
from time import sleep from time import sleep
import pytest import pytest
import redis
from django_q.brokers import Broker, get_broker from django_q.brokers import Broker, get_broker
from django_q.conf import Conf from django_q.conf import Conf
from django_q.humanhash import uuid from django_q.humanhash import uuid
from django_q.tests.settings import REDIS_HOST, MONGO_HOST from django_q.tests.settings import MONGO_HOST, REDIS_HOST
def test_broker(monkeypatch): def test_broker(monkeypatch):
+3 -1
View File
@@ -3,8 +3,9 @@ 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.cluster import monitor, pusher, worker
from django_q.conf import Conf from django_q.conf import Conf
from django_q.monitor import monitor
from django_q.pusher import pusher
from django_q.queues import Queue from django_q.queues import Queue
from django_q.tasks import ( from django_q.tasks import (
AsyncTask, AsyncTask,
@@ -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
+39 -12
View File
@@ -1,8 +1,8 @@
from datetime import datetime
import os import os
import sys import sys
import threading import threading
import uuid as uuidlib import uuid as uuidlib
from datetime import datetime
from math import copysign from math import copysign
from multiprocessing import Event, Value from multiprocessing import Event, Value
from time import sleep from time import sleep
@@ -11,14 +11,13 @@ from typing import Optional
import pytest import pytest
from django.utils import timezone from django.utils import timezone
myPath = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, myPath + "/../")
from django_q.brokers import Broker, get_broker from django_q.brokers import Broker, get_broker
from django_q.cluster import Cluster, Sentinel, monitor, pusher, save_task, worker 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 django_q.monitor import monitor, save_task
from django_q.pusher import pusher
from django_q.queues import Queue 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
@@ -34,6 +33,10 @@ from django_q.tasks import (
) )
from django_q.tests.tasks import TaskError, multiply 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__))
sys.path.insert(0, myPath + "/../")
class WordClass: class WordClass:
@@ -64,7 +67,7 @@ def test_sync(broker):
@pytest.mark.django_db @pytest.mark.django_db
def test_sync_raise_exception(broker): def test_sync_raise_exception(broker):
with pytest.raises(Exception): with pytest.raises(TaskError):
async_task("django_q.tests.tasks.raise_exception", broker=broker, sync=True) async_task("django_q.tests.tasks.raise_exception", broker=broker, sync=True)
@@ -138,6 +141,30 @@ def test_cluster(broker):
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"
@@ -409,6 +436,7 @@ def test_recycle(broker, monkeypatch):
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
@@ -442,15 +470,14 @@ def test_save_limit_per_func(broker, monkeypatch):
# 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
def test_max_rss(broker, monkeypatch): def test_max_rss(broker, monkeypatch):
# set up the Sentinel # set up the Sentinel
@@ -538,7 +565,6 @@ def test_attempt_count(broker, monkeypatch):
assert saved_task.attempt_count == 1 assert saved_task.attempt_count == 1
sleep(0.5) sleep(0.5)
# second save # second save
old_stopped = task["stopped"]
task["stopped"] = 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"])
@@ -770,6 +796,7 @@ def test_add_months():
assert new_date.month == 2 assert new_date.month == 2
assert new_date.day == 29 assert new_date.day == 29
@pytest.mark.django_db @pytest.mark.django_db
def test_add_years(): def test_add_years():
# add some months # add some months
+1 -1
View File
@@ -5,7 +5,7 @@ import pytest
from django_q.brokers import get_broker from django_q.brokers import get_broker
from django_q.cluster import Cluster from django_q.cluster import Cluster
from django_q.conf import Conf from django_q.conf import Conf
from django_q.monitor import get_ids, info, monitor from django_q.monitor_terminal import get_ids, info, monitor
from django_q.status import Stat from django_q.status import Stat
from django_q.tasks import async_task from django_q.tasks import async_task
+191 -52
View File
@@ -1,8 +1,9 @@
import os import os
from datetime import timedelta from datetime import datetime, timedelta
from multiprocessing import Event, Value from multiprocessing import Event, Value
from unittest import mock from unittest import mock
import django
import pytest import pytest
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
@@ -11,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.cluster import monitor, pusher, scheduler, worker, localtime
from django_q.conf import Conf from django_q.conf import Conf
from django_q.monitor import monitor
from django_q.pusher import pusher
from django_q.queues import Queue 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
@@ -21,11 +24,25 @@ from django_q.tests.testing_utilities.multiple_database_routers import (
TestingMultipleAppsDatabaseRouter, TestingMultipleAppsDatabaseRouter,
TestingReplicaDatabaseRouter, TestingReplicaDatabaseRouter,
) )
from django_q.utils import add_months, localtime
from django_q.worker import worker
if django.VERSION < (4, 0):
# pytz is the default in django 3.2. Remove when no support for 3.2
from pytz import timezone as ZoneInfo
else:
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
@pytest.fixture @pytest.fixture
def broker(monkeypatch) -> Broker: def broker(monkeypatch) -> Broker:
"""Patches the Conf object setting the DJANGO_REDIS attribute allowing a default redis configuration.""" """
Patches the Conf object setting the DJANGO_REDIS attribute allowing a default
redis configuration.
"""
monkeypatch.setattr(Conf, "DJANGO_REDIS", "default") monkeypatch.setattr(Conf, "DJANGO_REDIS", "default")
return get_broker() return get_broker()
@@ -36,25 +53,11 @@ def orm_broker(monkeypatch) -> None:
monkeypatch.setattr(Conf, "ORM", "default") monkeypatch.setattr(Conf, "ORM", "default")
@pytest.fixture
def orm_no_replica_broker(orm_broker, monkeypatch) -> Broker:
"""Generates a Broker with a disabled read replica database configuration."""
monkeypatch.setattr(Conf, "HAS_REPLICA", False)
return get_broker(list_key="scheduler_test:q")
@pytest.fixture
def orm_replica_broker(orm_broker, monkeypatch) -> Broker:
"""Generates a Broker with read replica database configuration."""
monkeypatch.setattr(Conf, "HAS_REPLICA", True)
return get_broker(list_key="scheduler_test:q")
REPLICA_DATABASE_ROUTERS = [ REPLICA_DATABASE_ROUTERS = [
f"{TestingReplicaDatabaseRouter.__module__}.{TestingReplicaDatabaseRouter.__name__}" f"{TestingReplicaDatabaseRouter.__module__}.{TestingReplicaDatabaseRouter.__name__}"
] ]
REPLICA_DATABASES = { REPLICA_DATABASES = {
"default": { "writable": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"), "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}, },
@@ -65,7 +68,7 @@ REPLICA_DATABASES = {
} }
MULTIPLE_APPS_DATABASE_ROUTERS = [ MULTIPLE_APPS_DATABASE_ROUTERS = [
f"{TestingMultipleAppsDatabaseRouter.__module__}.{TestingMultipleAppsDatabaseRouter.__name__}" f"{TestingMultipleAppsDatabaseRouter.__module__}.{TestingMultipleAppsDatabaseRouter.__name__}" # noqa: E501
] ]
MULTIPLE_APPS_DATABASES = { MULTIPLE_APPS_DATABASES = {
"default": { "default": {
@@ -79,6 +82,108 @@ MULTIPLE_APPS_DATABASES = {
} }
@pytest.mark.django_db
def test_scheduler_daylight_saving_time_daily(broker, monkeypatch):
# Set up a startdate in the Amsterdam timezone (without dst 1 hour ahead). The
# 28th of March 2021 is the day when sunlight saving starts (at 2 am)
monkeypatch.setattr(Conf, "TIME_ZONE", "Europe/Amsterdam")
tz = ZoneInfo("Europe/Amsterdam")
broker.list_key = "scheduler_test:q"
# 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)
start_date = datetime(2021, 3, 27, 1, 0, 0)
# Create schedule with the next run date on the start date. It will move one day
# forward when we run the scheduler
schedule = create_schedule(
"math.copysign",
1,
-1,
name="test math",
schedule_type=Schedule.DAILY,
next_run=start_date,
)
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
# It's now the day after exactly at midnight UTC
next_run = schedule.next_run
assert str(next_run) == "2021-03-28 00:00:00+00:00"
# In the Amsterdam timezone, it's 1 hour over midnight (+01)
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-03-28 01:00:00+01:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-03-28 23:00:00+00:00"
next_run = next_run.astimezone(tz)
# In the Amsterdam timezone, it's 1 hour over midnight (+02)
assert str(next_run) == "2021-03-29 01:00:00+02:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-03-29 23:00:00+00:00"
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-03-30 01:00:00+02:00"
# Create second schedule with the next run date on the start date. It will move
# one day forward when we run the scheduler
start_date = datetime(2021, 10, 29, 1, 0, 0)
schedule = create_schedule(
"django_q.tests.tasks.word_multiply",
2,
name="multiply",
schedule_type=Schedule.DAILY,
next_run=start_date,
)
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-10-29 23:00:00+00:00"
# In the Amsterdam timezone, it's 1 hour over midnight (+02)
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-10-30 01:00:00+02:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-10-30 23:00:00+00:00"
# In the Amsterdam timezone, it's 1 hour over midnight (+02)
next_run = next_run.astimezone(tz)
assert str(next_run) == "2021-10-31 01:00:00+02:00"
# Run scheduler so we get the next run date
scheduler(broker=broker)
schedule.refresh_from_db()
next_run = schedule.next_run
assert str(next_run) == "2021-11-01 00:00:00+00:00"
# In the Amsterdam timezone, it's 1 hour over midnight (+01)
# Switch of DST
next_run = next_run.astimezone(tz)
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"
@@ -228,7 +333,30 @@ def test_scheduler(broker, monkeypatch):
# Done # Done
broker.delete_queue() broker.delete_queue()
monkeypatch.setattr(Conf, "PREFIX", "some_cluster_name") # test bimonthly
schedule = create_schedule(
"django_q.tests.tasks.word_multiply",
2,
word="catch_up",
schedule_type=Schedule.BIMONTHLY,
)
scheduler(broker=broker)
schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.next_run.date() == add_months(timezone.now(), 2).date()
# test biweekly
schedule = create_schedule(
"django_q.tests.tasks.word_multiply",
2,
word="catch_up",
schedule_type=Schedule.BIWEEKLY,
)
scheduler(broker=broker)
schedule = Schedule.objects.get(pk=schedule.pk)
assert schedule.next_run.date() == (timezone.now() + timedelta(weeks=2)).date()
broker.delete_queue()
monkeypatch.setattr(Conf, "CLUSTER_NAME", "some_cluster_name")
# create a schedule on another cluster # create a schedule on another cluster
schedule = create_schedule( schedule = create_schedule(
"math.copysign", "math.copysign",
@@ -252,7 +380,7 @@ def test_scheduler(broker, monkeypatch):
# queue must be empty # queue must be empty
assert task_queue.qsize() == 0 assert task_queue.qsize() == 0
monkeypatch.setattr(Conf, "PREFIX", "default") monkeypatch.setattr(Conf, "CLUSTER_NAME", "default")
# create a schedule on the same cluster # create a schedule on the same cluster
schedule = create_schedule( schedule = create_schedule(
"math.copysign", "math.copysign",
@@ -277,61 +405,72 @@ def test_scheduler(broker, monkeypatch):
assert task_queue.qsize() == 1 assert task_queue.qsize() == 1
@override_settings(
DATABASE_ROUTERS=REPLICA_DATABASE_ROUTERS, DATABASES=REPLICA_DATABASES
)
@pytest.mark.django_db @pytest.mark.django_db
def test_scheduler_atomic_transaction_must_specify_a_database_when_no_replicas_are_used( def test_intended_schedule_kwarg(broker, monkeypatch):
orm_no_replica_broker: Broker, broker.list_key = "scheduler_test:q"
): broker.delete_queue()
""" run_date = timezone.now() - timedelta(hours=1)
GIVEN a environment without a read replica database schedule = create_schedule(
WHEN the scheduler is called "math.copysign",
THEN the transaction atomic must be called using the configured database in the Conf.ORM settings. 1,
""" -1,
broker = orm_no_replica_broker name="test math",
with mock.patch("django_q.cluster.db") as mocked_db: hook="django_q.tests.tasks.result",
scheduler(broker=broker) schedule_type=Schedule.HOURLY,
# The router should correctly set the database to use! repeats=1,
mocked_db.transaction.atomic.assert_called_with(using=broker.connection.db) next_run=run_date,
intended_date_kwarg="intended_date",
)
assert schedule.last_run() is None
assert schedule.intended_date_kwarg == "intended_date"
# run scheduler
scheduler(broker=broker)
# set up the workflow
task_queue = Queue()
stop_event = Event()
stop_event.set()
# push it
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_transaction_must_specify_no_database_when_read_write_replicas_are_used( def test_scheduler_atomic_must_specify_the_write_db(
orm_replica_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 without a specific database, thus letting the database router pick. THEN the transaction must be called with the write database.
""" """
with mock.patch("django_q.cluster.db") as mocked_db: broker = get_broker(list_key="scheduler_test:q")
scheduler(broker=orm_replica_broker) with mock.patch("django_q.cluster.db.transaction") as mocked_db:
# No specific databases should be set here, this is the job of the router! scheduler(broker=broker)
mocked_db.transaction.atomic.assert_called_with() mocked_db.atomic.assert_called_with(using="writable")
@override_settings( @override_settings(
DATABASE_ROUTERS=MULTIPLE_APPS_DATABASE_ROUTERS, DATABASES=MULTIPLE_APPS_DATABASES DATABASE_ROUTERS=MULTIPLE_APPS_DATABASE_ROUTERS, DATABASES=MULTIPLE_APPS_DATABASES
) )
@pytest.mark.django_db @pytest.mark.django_db
def test_scheduler_atomic_transaction_must_specify_the_database_based_on_router_redirection( def test_scheduler_atomic_must_specify_the_database_based_on_router_redirection(
orm_no_replica_broker: Broker, orm_broker: Broker,
): ):
""" """
GIVEN a environment without a read replica database GIVEN a environment without a read replica database
WHEN the scheduler is called WHEN the scheduler is called
THEN the transaction atomic must be called using the configured database in the Conf.ORM settings. THEN the transaction atomic must be called using the default connection.
""" """
broker = orm_no_replica_broker broker = get_broker(list_key="scheduler_test:q")
with mock.patch("django_q.cluster.db") as mocked_db: with mock.patch("django_q.cluster.db.transaction") as mocked_db:
scheduler(broker=broker) scheduler(broker=broker)
# The router should correctly set the database to use! mocked_db.atomic.assert_called_with(using="default")
assert broker.connection.db == "default"
mocked_db.transaction.atomic.assert_called_with(using=broker.connection.db)
def test_localtime(): def test_localtime():
@@ -12,9 +12,9 @@ class TestingReplicaDatabaseRouter:
def db_for_write(self, model, **hints): def db_for_write(self, model, **hints):
""" """
Always write to DEFAULT database Always write to WRITABLE database
""" """
return "default" return "writable"
class TestingMultipleAppsDatabaseRouter: class TestingMultipleAppsDatabaseRouter:
+38
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"))
+66 -8
View File
@@ -1,7 +1,23 @@
import datetime
from datetime import date
from django.utils.timezone import make_aware
import calendar import calendar
import inspect
from datetime import date, datetime
import django
from django import db
from django.conf import settings
from django.utils import timezone
from django_q.conf import Conf, logger
if django.VERSION < (4, 0):
# pytz is the default in django 3.2. Remove when no support for 3.2
from pytz import timezone as ZoneInfo
else:
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
# credits: https://stackoverflow.com/a/4131114 # credits: https://stackoverflow.com/a/4131114
# Made them aware of timezone # Made them aware of timezone
@@ -9,22 +25,64 @@ def add_months(d, months):
month = d.month - 1 + months month = d.month - 1 + months
year = d.year + month // 12 year = d.year + month // 12
month = month % 12 + 1 month = month % 12 + 1
day = min(d.day, calendar.monthrange(year,month)[1]) day = min(d.day, calendar.monthrange(year, month)[1])
return d.replace(year=year, month=month, day=day) return d.replace(year=year, month=month, day=day)
# credits: https://stackoverflow.com/a/15743908 # credits: https://stackoverflow.com/a/15743908
# Changed the last line to make it a little easier to read and changed it to move February 29 to 28 next year # Changed the last line to make it a little easier to read and changed it to move
# Also made them aware of timezone # February 29 to 28 next year.
def add_years(d, years): def add_years(d, years):
"""Return a date that's `years` years after the date (or datetime) """Return a date that's `years` years after the date (or datetime)
object `d`. Return the same calendar date (month and day) in the object `d`. Return the same calendar date (month and day) in the
destination year, if it exists, otherwise use the previous day destination year, if it exists, otherwise use the previous day
(thus changing February 29 to February 28). (thus changing February 29 to February 28).
""" """
try: try:
return d.replace(year = d.year + years) return d.replace(year=d.year + years)
except ValueError: except ValueError:
new_date = d + (date(d.year + years, 3, 1) - date(d.year, 3, 1)) new_date = d + (date(d.year + years, 3, 1) - date(d.year, 3, 1))
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:
"""Override for timezone.localtime to deal with naive times and local times"""
if settings.USE_TZ:
if django.VERSION >= (4, 0) and getattr(settings, "USE_DEPRECATED_PYTZ", False):
import pytz
convert_to_tz = pytz.timezone(Conf.TIME_ZONE)
else:
convert_to_tz = ZoneInfo(Conf.TIME_ZONE)
return timezone.localtime(value=value, timezone=convert_to_tz)
if value is None:
return datetime.now()
else:
return value
def close_old_django_connections():
"""
Close django connections unless running with sync=True.
"""
if Conf.SYNC:
logger.warning(
"Preserving django database connections because sync=True. Beware "
"that tasks are now injected in the calling context/transactions "
"which may result in unexpected behaviour."
)
else:
db.close_old_connections()
+141
View File
@@ -0,0 +1,141 @@
import pydoc
import traceback
from multiprocessing import Value
from multiprocessing.process import current_process
from multiprocessing.queues import Queue
from django import core
from django.apps.registry import apps
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
try:
apps.check_apps_ready()
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"]
# Log task creation and set process name
# Get the function from the task
func_name = get_func_repr(f)
task_name = task["name"]
task_desc = _("%(proc_name)s processing %(task_name)s '%(func_name)s'") % {
"proc_name": proc_name,
"func_name": func_name,
"task_name": task_name,
}
if "group" in task:
task_desc += f" [{task['group']}]"
logger.info(task_desc)
if setproctitle:
proc_title = f"qcluster {proc_name} processing {task_name} '{func_name}'"
if "group" in task:
proc_title += f" [{task['group']}]"
setproctitle.setproctitle(proc_title)
# if it's not an instance try to get it from the string
if not callable(f):
# locate() returns None if f cannot be loaded
f = pydoc.locate(f)
close_old_django_connections()
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
timer.value = -1 # Idle
if setproctitle:
setproctitle.setproctitle(f"qcluster {proc_name} idle")
# Recycle
if task_count == Conf.RECYCLE or rss_check():
timer.value = -2 # Recycled
break
logger.info(_("%(proc_name)s stopped doing work") % {"proc_name": proc_name})
def rss_check():
if Conf.MAX_RSS:
if resource:
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss >= Conf.MAX_RSS
elif psutil:
return psutil.Process().memory_info().rss >= Conf.MAX_RSS * 1024
return False
+4 -1
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)
+50 -26
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.
@@ -52,6 +52,30 @@ You can have multiple clusters on multiple machines, working on the same queue a
- They use the same cluster name. See :doc:`configure` - They use the same cluster name. See :doc:`configure`
- They share the same ``SECRET_KEY`` for Django. - They share the same ``SECRET_KEY`` for Django.
.. _multiple-queues
Multiple Queues
-----------------
You can have multiple queues in one Django site, and use multiple cluster to work on each queue.
Different queues are identified by different queue names which are also cluster names.
To run an alternate cluster, e.g. to work on the 'long' queue, start your cluster with command::
# On Linux
$ Q_CLUSTER_NAME=long python manage.py qcluster
# On Windows
$ python manage.py qcluster --name long
You can set different Q_CLUSTER options for alternative clusters, such as 'timeout', 'queue_limit'
and any other options which are valid in :doc:`configure`. See :ref:`alt-clusters`.
.. note::
To use multiple queue, use the keyword argument `cluster` in async_task() and schedule():
* if `cluster` is not set (the default), async_task() and schedule() will be handled by the default cluster;
* if `cluster` is set, only clusters with matching cluster name will run the task or do the schedule.
Using a Procfile Using a Procfile
---------------- ----------------
If you host on `Heroku <https://heroku.com>`__ or you are using `Honcho <https://github.com/nickstenning/honcho>`__ you can start the cluster from a :file:`Procfile` with an entry like this:: If you host on `Heroku <https://heroku.com>`__ or you are using `Honcho <https://github.com/nickstenning/honcho>`__ you can start the cluster from a :file:`Procfile` with an entry like this::
+44 -43
View File
@@ -16,12 +16,10 @@
import os import os
import sys import sys
import sphinx_rtd_theme
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 + "/../")
os.environ['DJANGO_SETTINGS_MODULE'] = 'django_q.tests.settings' os.environ["DJANGO_SETTINGS_MODULE"] = "django_q.tests.settings"
nitpick_ignore = [('py:class', 'datetime')] nitpick_ignore = [("py:class", "datetime")]
# If extensions (or modules to document with autodoc) are in another directory, # If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
@@ -37,50 +35,54 @@ nitpick_ignore = [('py:class', 'datetime')]
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx_rtd_theme', "sphinx_rtd_theme",
'sphinx.ext.todo', "sphinx.ext.todo",
'sphinx.ext.intersphinx', "sphinx.ext.intersphinx",
# 'sphinx.ext.autodoc' # 'sphinx.ext.autodoc'
] ]
intersphinx_mapping = {'python': ('https://docs.python.org/3.8', None), intersphinx_mapping = {
'django': ('https://docs.djangoproject.com/en/2.2/', "python": ("https://docs.python.org/3.8", None),
'https://docs.djangoproject.com/en/2.2/_objects/')} "django": (
"https://docs.djangoproject.com/en/2.2/",
"https://docs.djangoproject.com/en/2.2/_objects/",
),
}
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# The suffix(es) of source filenames. # The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string: # You can specify multiple suffix as a list of string:
# source_suffix = ['.rst', '.md'] # source_suffix = ['.rst', '.md']
source_suffix = '.rst' source_suffix = ".rst"
# The encoding of source files. # The encoding of source files.
# source_encoding = 'utf-8-sig' # source_encoding = 'utf-8-sig'
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = "index"
# General information about the project. # General information about the project.
project = 'Django Q2' project = "Django Q2"
copyright = '2015-2021, Ilan Steemers - 2022, Stan Triepels' copyright = "2015-2021, Ilan Steemers - 2022, Stan Triepels"
author = 'Ilan Steemers, Stan Triepels' author = "Ilan Steemers, Stan Triepels"
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '1.4' version = "1.7"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '1.4.2' 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.
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # Usually you set "language" from the command line for these cases.
language = 'en' language = "en"
# There are two options for replacing |today|: either, you set today to some # There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used: # non-false value, then it is used:
@@ -90,7 +92,7 @@ language = 'en'
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
exclude_patterns = ['_build'] exclude_patterns = ["_build"]
# The reST default role (used for this markup: `text`) to use for all # The reST default role (used for this markup: `text`) to use for all
# documents. # documents.
@@ -108,7 +110,7 @@ add_module_names = False
# show_authors = False # show_authors = False
# The name of the Pygments (syntax highlighting) style to use. # The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx' pygments_style = "sphinx"
# A list of ignored prefixes for module index sorting. # A list of ignored prefixes for module index sorting.
# modindex_common_prefix = [] # modindex_common_prefix = []
@@ -124,7 +126,7 @@ todo_include_todos = True
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
html_theme = 'sphinx_rtd_theme' html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme # Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the # further. For a list of options available for each theme, see the
@@ -136,11 +138,11 @@ html_theme_options = {
# 'github_banner': True, # 'github_banner': True,
} }
html_sidebars = { html_sidebars = {
'**': [ "**": [
'about.html', "about.html",
'navigation.html', "navigation.html",
'relations.html', "relations.html",
'searchbox.html', "searchbox.html",
] ]
} }
# Add any paths that contain custom themes here, relative to this directory. # Add any paths that contain custom themes here, relative to this directory.
@@ -161,12 +163,12 @@ html_sidebars = {
# The name of an image file (within the static path) to use as favicon of the # The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large. # pixels large.
html_favicon = '_static/favicon.ico' html_favicon = "_static/favicon.ico"
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ["_static"]
# Add any extra paths that contain custom files (such as robots.txt or # Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied # .htaccess) here, relative to this directory. These files are copied
@@ -229,20 +231,17 @@ html_static_path = ['_static']
# html_search_scorer = 'scorer.js' # html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder. # Output file base name for HTML help builder.
htmlhelp_basename = 'DjangoQ2doc' htmlhelp_basename = "DjangoQ2doc"
# -- Options for LaTeX output --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
latex_elements = { latex_elements = {
# The paper size ('letterpaper' or 'a4paper'). # The paper size ('letterpaper' or 'a4paper').
# 'papersize': 'letterpaper', # 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt'). # The font size ('10pt', '11pt' or '12pt').
# 'pointsize': '10pt', # 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble. # Additional stuff for the LaTeX preamble.
# 'preamble': '', # 'preamble': '',
# Latex figure (float) alignment # Latex figure (float) alignment
# 'figure_align': 'htbp', # 'figure_align': 'htbp',
} }
@@ -251,8 +250,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
(master_doc, 'DjangoQ2.tex', 'Django Q2 Documentation', (master_doc, "DjangoQ2.tex", "Django Q2 Documentation", "Ilan Steemers", "manual"),
'Ilan Steemers', 'manual'),
] ]
# The name of an image file (relative to this directory) to place at the top of # The name of an image file (relative to this directory) to place at the top of
@@ -280,10 +278,7 @@ latex_documents = [
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [(master_doc, "djangoq2", "Django Q2 Documentation", [author], 1)]
(master_doc, 'djangoq2', 'Django Q2 Documentation',
[author], 1)
]
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
@@ -296,9 +291,15 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
(master_doc, 'DjangoQ2', 'Django Q2 Documentation', (
author, 'DjangoQ2', 'A multiprocessing distributed task queue for Django.', master_doc,
'Miscellaneous'), "DjangoQ2",
"Django Q2 Documentation",
author,
"DjangoQ2",
"A multiprocessing distributed task queue for Django.",
"Miscellaneous",
),
] ]
# Documents to append as an appendix to all manuals. # Documents to append as an appendix to all manuals.
+40 -12
View File
@@ -20,7 +20,18 @@ Configuration is handled via the ``Q_CLUSTER`` dictionary in your :file:`setting
'redis': { 'redis': {
'host': '127.0.0.1', 'host': '127.0.0.1',
'port': 6379, 'port': 6379,
'db': 0, } 'db': 0, },
'ALT_CLUSTERS': {
'long': {
'timeout': 3000,
'retry': 3600,
'max_attempts': 2,
},
'short': {
'timeout': 10,
'max_attempts': 1,
},
}
} }
All configuration settings are optional: All configuration settings are optional:
@@ -68,8 +79,17 @@ 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
~~~~~~~
The timezone that is used for task scheduling. Use this if you are having issue with DST. The scheduler uses UTC to calculate the next date and will therefore ignore any DST changes. This will cause 1 hour or 0.5 hour changes in the schedule when time is moved one hour ahead or back. Defaults to `settings.TIME_ZONE` if `USE_TZ` is enabled.
.. _ack_failures: .. _ack_failures:
ack_failures ack_failures
@@ -310,16 +330,6 @@ Using the Django ORM backend will also enable the Queued Tasks table in the Admi
If you need better performance , you should consider using a different database backend than the main project. If you need better performance , you should consider using a different database backend than the main project.
Set ``orm`` to the name of that database connection and make sure you run migrations on it using the ``--database`` option. Set ``orm`` to the name of that database connection and make sure you run migrations on it using the ``--database`` option.
When using the Django database as a message broker, you can set the ``has_replica`` boolean keyword to ensure Django-Q2 works properly letting a `Database Router <https://docs.djangoproject.com/en/3.2/topics/db/multi-db/>`__. ::
# example ORM broker connection with replica database
Q_CLUSTER = {
...
'orm': 'default',
'has_replica': True
}
.. _mongo_configuration: .. _mongo_configuration:
mongo mongo
@@ -340,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
@@ -462,6 +472,24 @@ As a rule of thumb; cpu_affinity 1 favors repetitive short running tasks, while
*Psutil does not support cpu affinity on OS X at this time.* *Psutil does not support cpu affinity on OS X at this time.*
.. _alt-clusters:
ALT_CLUSTERS
~~~~~~~~~~~~
For multiple clusters working on multiple queues to run in one Django site.
ALT_CLUSTERS should be a dict with cluster_name as its key, and the value is the configuration for the cluster
with the key as its name. The configuration items are consistent with Q_CLUSTER,
except for a few items such as name/cluster_name/ALT_CLUSTER, which are not available of course.
See :ref:`multiple-queues`.
.. note::
For a cluster, if its name is in ALT_CLUSTERS, the config item in ALT_CLUSTER will override
the same config item in the Q_CLUSTER root. Other config items in Q_CLUSTER root remain in effect for this cluster.
.. py:module:: django_q .. py:module:: django_q
.. rubric:: Footnotes .. rubric:: Footnotes
-1
View File
@@ -1,4 +1,3 @@
version: "3.0"
services: services:
docs: docs:
container_name: djangoq2-docs container_name: djangoq2-docs
+1 -1
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
+1 -1
View File
@@ -27,7 +27,7 @@ Features
- Rollbar and Sentry support - Rollbar and Sentry support
Django Q2 is tested with: Python 3.7, 3.8, 3.9 and 3.10, Django 2.2.x and 3.2.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.
+29 -9
View File
@@ -4,7 +4,7 @@ Installation
- Install the latest version with pip:: - Install the latest version with pip::
$ pip install django-q $ pip install django-q2
- Add :mod:`django_q` to ``INSTALLED_APPS`` in your projects :file:`settings.py`:: - Add :mod:`django_q` to ``INSTALLED_APPS`` in your projects :file:`settings.py`::
@@ -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.7, 3.8, 3.9 and 3.10 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>`__
@@ -55,6 +71,10 @@ Optional
$ pip install psutil $ pip install psutil
- `setproctitle <https://github.com/dvarrazzo/py-setproctitle>`__ python module to customize the process title by Daniele Varrazzo', is an optional requirement used to set informative process titles::
$ pip install setproctitle
- `Hiredis <https://github.com/redis/hiredis>`__ parser. This C library maintained by the core Redis team is faster than the standard PythonParser during high loads:: - `Hiredis <https://github.com/redis/hiredis>`__ parser. This C library maintained by the core Redis team is faster than the standard PythonParser during high loads::
$ pip install hiredis $ pip install hiredis
@@ -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.7, 3.8, 3.9 and 3.10 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.
+1 -1
View File
@@ -111,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)
+25 -3
View File
@@ -69,6 +69,10 @@ You can change this by setting the :ref:`catch_up` configuration setting to ``Fa
The scheduler will then skip execution of scheduled events in the past. The scheduler will then skip execution of scheduled events in the past.
Instead those tasks will run once when the cluster starts again and the scheduler will find the next available slot in the future according to original schedule parameters. Instead those tasks will run once when the cluster starts again and the scheduler will find the next available slot in the future according to original schedule parameters.
When :ref:`catch_up` is to ``True`` it may be useful for the task to know what was the date and time it was originally intended to run at.
To achieve this, pass an identifier name to parameter `intended_date_kwarg` when creating the schedule. The intended datetime will then be passed - in isoformat string - as
a kwarg with that identifier name to the task that has been created.
Management Commands Management Commands
------------------- -------------------
@@ -123,9 +127,10 @@ Reference
:param int repeats: Number of times to repeat schedule. -1=Always, 0=Never, n =n. :param int repeats: Number of times to repeat schedule. -1=Always, 0=Never, n =n.
:param datetime next_run: Next or first scheduled execution datetime. :param datetime next_run: Next or first scheduled execution datetime.
:param str cluster: optional cluster name. Task will be executed only on a cluster with a matching :ref:`name`. :param str cluster: optional cluster name. Task will be executed only on a cluster with a matching :ref:`name`.
:param str intended_date_kwarg: optional identifier to pass intended schedule date.
:param dict q_options: options passed to async_task for this schedule :param dict q_options: options passed to async_task for this schedule
:param kwargs: optional keyword arguments for the scheduled function. :param kwargs: optional keyword arguments for the scheduled function.
.. note:: .. note::
q_options does not accept the 'broker' key with a broker instance but accepts a 'broker_name' key instead. This can be used to specify the broker connection name to assign the task. If a broker with the specified name does not exist or is not running at the moment of placing the task in queue it fallbacks to the random broker/queue that handled the schedule. q_options does not accept the 'broker' key with a broker instance but accepts a 'broker_name' key instead. This can be used to specify the broker connection name to assign the task. If a broker with the specified name does not exist or is not running at the moment of placing the task in queue it fallbacks to the random broker/queue that handled the schedule.
@@ -165,7 +170,7 @@ Reference
.. py:attribute:: TYPE .. py:attribute:: TYPE
:attr:`ONCE`, :attr:`MINUTES`, :attr:`HOURLY`, :attr:`DAILY`, :attr:`WEEKLY`, :attr:`MONTHLY`, :attr:`QUARTERLY`, :attr:`YEARLY`, :attr:`CRON` :attr:`ONCE`, :attr:`MINUTES`, :attr:`HOURLY`, :attr:`DAILY`, :attr:`WEEKLY`, :attr:`BIWEEKLY`, :attr:`MONTHLY`, :attr:`BIMONTHLY`, :attr:`QUARTERLY`, :attr:`YEARLY`, :attr:`CRON`
.. py:attribute:: minutes .. py:attribute:: minutes
@@ -183,9 +188,13 @@ Reference
When set to -1, this will keep counting down. When set to -1, this will keep counting down.
.. py:attribute:: cluster .. py:attribute:: cluster
Task will be executed only on a cluster with a matching :ref:`name`. Task will be executed only on a cluster with a matching :ref:`name`.
.. py:attribute:: intended_date_kwarg
Name of kwarg to pass intended schedule date.
.. py:attribute:: next_run .. py:attribute:: next_run
Datetime of the next scheduled execution. Datetime of the next scheduled execution.
@@ -224,10 +233,23 @@ Reference
`'W'` the task will run every week on they day and time of the first run. `'W'` the task will run every week on they day and time of the first run.
.. py:attribute:: BIWEEKLY
`'BW'` the task will run once every two weeks on they day and time of the first run.
.. py:attribute:: MONTHLY .. py:attribute:: MONTHLY
`'M'` the tasks runs every month on they day and time of the last run. `'M'` the tasks runs every month on they day and time of the last run.
.. note::
Months are tricky. If you schedule something on the 31st of the month and the next month has only 30 days or less, the task will run on the last day of the next month.
It will however continue to run on that day, e.g. the 28th, in subsequent months.
.. py:attribute:: BIMONTHLY
`'BM'` the tasks runs once every two months on they day and time of the last run.
.. note:: .. note::
Months are tricky. If you schedule something on the 31st of the month and the next month has only 30 days or less, the task will run on the last day of the next month. Months are tricky. If you schedule something on the 31st of the month and the next month has only 30 days or less, the task will run on the last day of the next month.
+11 -1
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")
+8 -2
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
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
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
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
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
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()
Generated
+946 -1014
View File
File diff suppressed because it is too large Load Diff
+15 -9
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.4.2" version = "1.7.2"
packages = [ packages = [
{ include = "django_q" }, { include = "django_q" },
] ]
@@ -26,10 +30,11 @@ classifiers = [
'Operating System :: MacOS', 'Operating System :: MacOS',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.8',
'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.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.14, <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,10 +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}
importlib-metadata = {version = ">=3.6", python = "<3.10"}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
@@ -66,13 +73,12 @@ 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>=1.0.0"] requires = ["poetry_core"]
build-backend = ["poetry.core.masonry.api"] build-backend = ["poetry.core.masonry.api"]
testing = ["django-redis", "croniter", "hiredis", "psutil", "iron-mq", "boto3", "pymongo", "blessed", "redis"] testing = ["django-redis", "croniter", "hiredis", "psutil", "iron-mq", "boto3", "pymongo", "blessed", "redis", "setproctitle"]
rollbar = ["django-q-rollbar"] rollbar = ["django-q-rollbar"]
sentry = ["django-q-sentry"] sentry = ["django-q-sentry"]
+41 -40
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
View File
@@ -0,0 +1 @@
lint.extend-select = ["I"]
-2
View File
@@ -1,5 +1,3 @@
version: '3'
services: services:
redis: redis:
image: redis:latest image: redis:latest
+2
View File
@@ -0,0 +1,2 @@
[flake8]
max-line-length=88
+8 -3
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