Optional Cron expression scheduling - inital commit

This commit is contained in:
Ilan Steemers
2020-06-28 15:26:53 +02:00
parent 29b7e27573
commit ace3d01da7
7 changed files with 131 additions and 63 deletions
+25 -38
View File
@@ -10,14 +10,7 @@ from django_q.tasks import async_task
class TaskAdmin(admin.ModelAdmin): class TaskAdmin(admin.ModelAdmin):
"""model admin for success tasks.""" """model admin for success tasks."""
list_display = ( list_display = ("name", "func", "started", "stopped", "time_taken", "group")
'name',
'func',
'started',
'stopped',
'time_taken',
'group'
)
def has_add_permission(self, request): def has_add_permission(self, request):
"""Don't allow adds.""" """Don't allow adds."""
@@ -28,9 +21,9 @@ class TaskAdmin(admin.ModelAdmin):
qs = super(TaskAdmin, self).get_queryset(request) qs = super(TaskAdmin, self).get_queryset(request)
return qs.filter(success=True) return qs.filter(success=True)
search_fields = ('name', 'func', 'group') search_fields = ("name", "func", "group")
readonly_fields = [] readonly_fields = []
list_filter = ('group',) list_filter = ("group",)
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
"""Set all fields readonly.""" """Set all fields readonly."""
@@ -50,21 +43,15 @@ 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 = ( list_display = ("name", "func", "started", "stopped", "short_result")
'name',
'func',
'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 = [retry_failed]
search_fields = ('name', 'func') search_fields = ("name", "func")
list_filter = ('group',) list_filter = ("group",)
readonly_fields = [] readonly_fields = []
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
@@ -76,31 +63,31 @@ class ScheduleAdmin(admin.ModelAdmin):
""" model admin for schedules """ """ model admin for schedules """
list_display = ( list_display = (
'id', "id",
'name', "name",
'func', "func",
'schedule_type', "schedule_type",
'repeats', "repeats",
'next_run', "next_run",
'last_run', "last_run",
'success' "success",
) )
list_filter = ('next_run', 'schedule_type') # optional cron strings
search_fields = ('func',) try:
list_display_links = ('id', 'name') from croniter import croniter
except ImportError:
readonly_fields = ("cron",)
list_filter = ("next_run", "schedule_type")
search_fields = ("func",)
list_display_links = ("id", "name")
class QueueAdmin(admin.ModelAdmin): class QueueAdmin(admin.ModelAdmin):
""" queue admin for ORM broker """ """ queue admin for ORM broker """
list_display = (
'id', list_display = ("id", "key", "task_id", "name", "func", "lock")
'key',
'task_id',
'name',
'func',
'lock'
)
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)
+33 -19
View File
@@ -1,6 +1,5 @@
import ast
# Standard # Standard
import ast
import importlib import importlib
import signal import signal
import socket import socket
@@ -9,9 +8,8 @@ import uuid
from multiprocessing import Event, Process, Value, current_process from multiprocessing import Event, Process, Value, current_process
from time import sleep from time import sleep
# external # External
import arrow import arrow
# Django # Django
from django import db from django import db
from django.conf import settings from django.conf import settings
@@ -29,6 +27,12 @@ from django_q.signals import pre_execute
from django_q.signing import SignedPackage, BadSignature from django_q.signing import SignedPackage, BadSignature
from django_q.status import Stat, Status from django_q.status import Stat, Status
# Optional
try:
from croniter import croniter
except ImportError:
croniter = None
class Cluster: class Cluster:
def __init__(self, broker: Broker = None): def __init__(self, broker: Broker = None):
@@ -103,10 +107,10 @@ class Cluster:
@property @property
def is_stopping(self) -> bool: def is_stopping(self) -> bool:
return ( return (
self.stop_event self.stop_event
and self.start_event and self.start_event
and self.start_event.is_set() and self.start_event.is_set()
and self.stop_event.is_set() and self.stop_event.is_set()
) )
@property @property
@@ -116,13 +120,13 @@ class Cluster:
class Sentinel: class Sentinel:
def __init__( def __init__(
self, self,
stop_event, stop_event,
start_event, start_event,
cluster_id, cluster_id,
broker=None, broker=None,
timeout=Conf.TIMEOUT, timeout=Conf.TIMEOUT,
start=True, start=True,
): ):
# Make sure we catch signals for the pool # Make sure we catch signals for the pool
signal.signal(signal.SIGINT, signal.SIG_IGN) signal.signal(signal.SIGINT, signal.SIG_IGN)
@@ -377,7 +381,7 @@ def monitor(result_queue: Queue, broker: Broker = None):
def worker( def worker(
task_queue: Queue, result_queue: Queue, timer: Value, timeout: int = Conf.TIMEOUT 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 Takes a task from the task queue, tries to execute it and puts the result back in the result queue
@@ -552,9 +556,9 @@ def scheduler(broker: Broker = None):
try: try:
with db.transaction.atomic(using=Schedule.objects.db): with db.transaction.atomic(using=Schedule.objects.db):
for s in ( for s in (
Schedule.objects.select_for_update() Schedule.objects.select_for_update()
.exclude(repeats=0) .exclude(repeats=0)
.filter(next_run__lt=timezone.now()) .filter(next_run__lt=timezone.now())
): ):
args = () args = ()
kwargs = {} kwargs = {}
@@ -591,6 +595,16 @@ def scheduler(broker: Broker = None):
next_run = next_run.shift(months=+3) next_run = next_run.shift(months=+3)
elif s.schedule_type == s.YEARLY: elif s.schedule_type == s.YEARLY:
next_run = next_run.shift(years=+1) next_run = next_run.shift(years=+1)
elif s.schedule_type == s.CRON:
if not croniter:
raise ImportError(
_(
"Please install croniter to enable cron expressions"
)
)
next_run = arrow.get(
croniter(s.cron, timezone.now()).get_next()
)
if Conf.CATCH_UP or next_run > arrow.utcnow(): if Conf.CATCH_UP or next_run > arrow.utcnow():
break break
# arrow always returns a tz aware datetime, and we don't want # arrow always returns a tz aware datetime, and we don't want
+34 -3
View File
@@ -1,16 +1,26 @@
# Django
from django import get_version from django import get_version
from django.core.exceptions import ValidationError
from django.db import models
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.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db import models
from django.utils import timezone # External
from picklefield import PickledObjectField from picklefield import PickledObjectField
from picklefield.fields import dbsafe_decode from picklefield.fields import dbsafe_decode
# Local
from django_q.signing import SignedPackage from django_q.signing import SignedPackage
# Optional
try:
from croniter import croniter
except ImportError:
croniter = None
class Task(models.Model): class Task(models.Model):
id = models.CharField(max_length=32, primary_key=True, editable=False) id = models.CharField(max_length=32, primary_key=True, editable=False)
@@ -131,6 +141,18 @@ class Failure(Task):
proxy = True proxy = True
# Optional Cron validator
def validate_cron(value):
if not value:
return
if not croniter:
raise ImportError(_("Please install croniter to enable cron expressions"))
try:
croniter.expand(value)
except ValueError as e:
raise ValidationError(e)
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")
@@ -152,6 +174,7 @@ class Schedule(models.Model):
MONTHLY = "M" MONTHLY = "M"
QUARTERLY = "Q" QUARTERLY = "Q"
YEARLY = "Y" YEARLY = "Y"
CRON = "C"
TYPE = ( TYPE = (
(ONCE, _("Once")), (ONCE, _("Once")),
(MINUTES, _("Minutes")), (MINUTES, _("Minutes")),
@@ -161,6 +184,7 @@ class Schedule(models.Model):
(MONTHLY, _("Monthly")), (MONTHLY, _("Monthly")),
(QUARTERLY, _("Quarterly")), (QUARTERLY, _("Quarterly")),
(YEARLY, _("Yearly")), (YEARLY, _("Yearly")),
(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=1, choices=TYPE, default=TYPE[0][0], verbose_name=_("Schedule Type")
@@ -174,6 +198,13 @@ class Schedule(models.Model):
next_run = models.DateTimeField( next_run = models.DateTimeField(
verbose_name=_("Next Run"), default=timezone.now, null=True verbose_name=_("Next Run"), default=timezone.now, null=True
) )
cron = models.CharField(
max_length=100,
null=True,
blank=True,
validators=[validate_cron],
help_text=_("Cron expression"),
)
task = models.CharField(max_length=100, null=True, editable=False) task = models.CharField(max_length=100, null=True, editable=False)
def success(self): def success(self):
Generated
+33 -1
View File
@@ -120,6 +120,18 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.4.3" version = "0.4.3"
[[package]]
category = "main"
description = "croniter provides iteration for datetime object with cron like format"
name = "croniter"
optional = true
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "0.3.34"
[package.dependencies]
natsort = "*"
python-dateutil = "*"
[[package]] [[package]]
category = "main" category = "main"
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
@@ -270,6 +282,18 @@ optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
version = "8.4.0" version = "8.4.0"
[[package]]
category = "main"
description = "Simple yet flexible natural sorting in Python."
name = "natsort"
optional = true
python-versions = ">=3.4"
version = "7.0.1"
[package.extras]
fast = ["fastnumbers (>=2.0.0)"]
icu = ["PyICU (>=1.0.0)"]
[[package]] [[package]]
category = "dev" category = "dev"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
@@ -497,7 +521,7 @@ rollbar = ["django-q-rollbar"]
sentry = [] sentry = []
[metadata] [metadata]
content-hash = "d89c929e8b6951968228b1aab1aa5c0a7c014acceb3b986bbaa3e41df57d8b39" content-hash = "6d89bff8bd465aa4a5facd812172a970bf5edebdd8099141054f72aa63260c2a"
python-versions = ">=3.6" python-versions = ">=3.6"
[metadata.files] [metadata.files]
@@ -545,6 +569,10 @@ colorama = [
{file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
] ]
croniter = [
{file = "croniter-0.3.34-py2.py3-none-any.whl", hash = "sha256:15597ef0639f8fbab09cbf8c277fa8c65c8b9dbe818c4b2212f95dbc09c6f287"},
{file = "croniter-0.3.34.tar.gz", hash = "sha256:7186b9b464f45cf3d3c83a18bc2344cc101d7b9fd35a05f2878437b14967e964"},
]
django = [ django = [
{file = "Django-3.0.7-py3-none-any.whl", hash = "sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"}, {file = "Django-3.0.7-py3-none-any.whl", hash = "sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"},
{file = "Django-3.0.7.tar.gz", hash = "sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2"}, {file = "Django-3.0.7.tar.gz", hash = "sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2"},
@@ -638,6 +666,10 @@ more-itertools = [
{file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"},
{file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"},
] ]
natsort = [
{file = "natsort-7.0.1-py3-none-any.whl", hash = "sha256:d3fd728a3ceb7c78a59aa8539692a75e37cbfd9b261d4d702e8016639820f90a"},
{file = "natsort-7.0.1.tar.gz", hash = "sha256:a633464dc3a22b305df0f27abcb3e83515898aa1fd0ed2f9726c3571a27258cf"},
]
packaging = [ packaging = [
{file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
{file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "django-q" name = "django-q"
version = "1.2.4" version = "1.3.0"
description = "A multiprocessing distributed task queue for Django" description = "A multiprocessing distributed task queue for Django"
authors = ["Ilan Steemers <koed00@gmail.com>"] authors = ["Ilan Steemers <koed00@gmail.com>"]
license = "MIT" license = "MIT"
@@ -52,6 +52,7 @@ django-redis = {version = "^4.12.1", optional = true}
iron-mq = {version = "^0.9", optional = true} iron-mq = {version = "^0.9", optional = true}
boto3 = {version = "^1.14.12", optional = true} boto3 = {version = "^1.14.12", optional = true}
pymongo = {version = "^3.10.1", optional = true} pymongo = {version = "^3.10.1", optional = true}
croniter = {version = "^0.3.34", optional = true}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^5.4.2" pytest = "^5.4.2"
+1
View File
@@ -8,3 +8,4 @@ django-redis
iron-mq iron-mq
boto3 boto3
pymongo pymongo
croniter
+3 -1
View File
@@ -11,6 +11,7 @@ boto3==1.14.12 # via -r requirements.in
botocore==1.17.12 # via boto3, s3transfer botocore==1.17.12 # via boto3, s3transfer
certifi==2020.6.20 # via requests certifi==2020.6.20 # via requests
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
croniter==0.3.34 # via -r requirements.in
django-picklefield==3.0.1 # via -r requirements.in django-picklefield==3.0.1 # via -r requirements.in
django-redis==4.12.1 # via -r requirements.in django-redis==4.12.1 # via -r requirements.in
django==3.0.7 # via django-picklefield, django-redis django==3.0.7 # via django-picklefield, django-redis
@@ -20,9 +21,10 @@ idna==2.10 # via requests
iron-core==1.2.0 # via iron-mq iron-core==1.2.0 # via iron-mq
iron-mq==0.9 # via -r requirements.in iron-mq==0.9 # via -r requirements.in
jmespath==0.10.0 # via boto3, botocore jmespath==0.10.0 # via boto3, botocore
natsort==7.0.1 # via croniter
psutil==5.7.0 # via -r requirements.in psutil==5.7.0 # via -r requirements.in
pymongo==3.10.1 # via -r requirements.in pymongo==3.10.1 # via -r requirements.in
python-dateutil==2.8.1 # via arrow, botocore, iron-core python-dateutil==2.8.1 # via arrow, botocore, croniter, iron-core
pytz==2020.1 # via django pytz==2020.1 # via django
redis==3.5.3 # via -r requirements.in, django-redis redis==3.5.3 # via -r requirements.in, django-redis
requests==2.24.0 # via iron-core requests==2.24.0 # via iron-core