django-periodic-tasks 0.1.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ from django_periodic_tasks.decorators import exactly_once
2
+ from django_periodic_tasks.registry import (
3
+ ScheduleEntry,
4
+ ScheduleRegistry,
5
+ schedule_registry,
6
+ scheduled_task,
7
+ )
8
+
9
+ __all__ = [
10
+ "ScheduleEntry",
11
+ "ScheduleRegistry",
12
+ "exactly_once",
13
+ "schedule_registry",
14
+ "scheduled_task",
15
+ ]
@@ -0,0 +1,86 @@
1
+ from typing import Any
2
+
3
+ from django.contrib import admin
4
+ from django.http import HttpRequest
5
+
6
+ from django_periodic_tasks.models import ScheduledTask, TaskExecution
7
+
8
+ # Fields that are always read-only (computed/tracking)
9
+ TRACKING_READONLY = ("source", "last_run_at", "next_run_at", "total_run_count", "created_at", "updated_at")
10
+
11
+ # All editable model fields
12
+ ALL_FIELDS = (
13
+ "name",
14
+ "task_path",
15
+ "cron_expression",
16
+ "timezone",
17
+ "args",
18
+ "kwargs",
19
+ "source",
20
+ "enabled",
21
+ "queue_name",
22
+ "priority",
23
+ "backend",
24
+ "last_run_at",
25
+ "next_run_at",
26
+ "total_run_count",
27
+ "created_at",
28
+ "updated_at",
29
+ )
30
+
31
+
32
+ @admin.register(ScheduledTask)
33
+ class ScheduledTaskAdmin(admin.ModelAdmin[ScheduledTask]):
34
+ list_display = (
35
+ "name",
36
+ "task_path",
37
+ "cron_expression",
38
+ "source",
39
+ "enabled",
40
+ "last_run_at",
41
+ "next_run_at",
42
+ "total_run_count",
43
+ )
44
+ list_filter = ("source", "enabled")
45
+ search_fields = ("name", "task_path")
46
+ ordering = ("name",)
47
+
48
+ def get_readonly_fields(
49
+ self,
50
+ request: HttpRequest,
51
+ obj: ScheduledTask | None = None,
52
+ ) -> tuple[str, ...]:
53
+ if obj is None:
54
+ # New task being created
55
+ return TRACKING_READONLY
56
+ if obj.source == ScheduledTask.Source.CODE:
57
+ # Code-defined tasks: everything is read-only
58
+ return ALL_FIELDS
59
+ # DB-defined tasks: only tracking fields are read-only
60
+ return TRACKING_READONLY
61
+
62
+ def has_delete_permission(
63
+ self,
64
+ request: HttpRequest,
65
+ obj: Any = None,
66
+ ) -> bool:
67
+ if obj is not None and obj.source == ScheduledTask.Source.CODE:
68
+ return False
69
+ return super().has_delete_permission(request, obj)
70
+
71
+
72
+ @admin.register(TaskExecution)
73
+ class TaskExecutionAdmin(admin.ModelAdmin[TaskExecution]):
74
+ list_display = ("id", "scheduled_task", "status", "created_at", "completed_at")
75
+ list_filter = ("status",)
76
+ search_fields = ("scheduled_task__name",)
77
+ ordering = ("-created_at",)
78
+
79
+ def has_add_permission(self, request: HttpRequest) -> bool:
80
+ return False
81
+
82
+ def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
83
+ return False
84
+
85
+ def has_delete_permission(self, request: HttpRequest, obj: Any = None) -> bool:
86
+ return False
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ import logging
5
+
6
+ from django.apps import AppConfig
7
+ from django.conf import settings
8
+ from django.utils.module_loading import autodiscover_modules
9
+
10
+ if TYPE_CHECKING:
11
+ from django_periodic_tasks.scheduler import PeriodicTaskScheduler
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _scheduler: PeriodicTaskScheduler | None = None
16
+
17
+
18
+ class DjangoPeriodicTasksConfig(AppConfig):
19
+ name = "django_periodic_tasks"
20
+ verbose_name = "Django Periodic Tasks"
21
+ default_auto_field = "django.db.models.BigAutoField"
22
+
23
+ def ready(self) -> None:
24
+ autodiscover_modules("tasks")
25
+
26
+ global _scheduler # noqa: PLW0603
27
+
28
+ if not getattr(settings, "PERIODIC_TASKS_AUTOSTART", False):
29
+ return
30
+
31
+ if _scheduler is not None:
32
+ return
33
+
34
+ from django_periodic_tasks.scheduler import PeriodicTaskScheduler
35
+
36
+ interval: int = getattr(settings, "PERIODIC_TASKS_SCHEDULER_INTERVAL", 15)
37
+ _scheduler = PeriodicTaskScheduler(interval=interval)
38
+ _scheduler.start()
39
+ logger.info("Auto-started periodic task scheduler (interval=%ds)", interval)
@@ -0,0 +1,41 @@
1
+ from datetime import datetime, timezone
2
+ from zoneinfo import ZoneInfo
3
+
4
+ from croniter import CroniterBadCronError, croniter
5
+
6
+
7
+ def validate_cron_expression(expression: str) -> bool:
8
+ """Validate a cron expression string."""
9
+ result: bool = croniter.is_valid(expression)
10
+ return result
11
+
12
+
13
+ def compute_next_run_at(
14
+ cron_expression: str,
15
+ timezone_name: str = "UTC",
16
+ base_time: datetime | None = None,
17
+ ) -> datetime:
18
+ """Compute the next run time from a cron expression.
19
+
20
+ The base_time is interpreted in the given timezone for cron matching,
21
+ but the result is always returned in UTC.
22
+ """
23
+ try:
24
+ tz = ZoneInfo(timezone_name)
25
+ except (KeyError, ValueError) as e:
26
+ raise ValueError(f"Invalid timezone: {timezone_name}") from e
27
+
28
+ if base_time is None:
29
+ base_time = datetime.now(tz=timezone.utc)
30
+
31
+ # Convert base_time to the target timezone for correct cron matching
32
+ base_in_tz = base_time.astimezone(tz)
33
+
34
+ try:
35
+ cron = croniter(cron_expression, base_in_tz)
36
+ except (CroniterBadCronError, KeyError, ValueError) as e:
37
+ raise ValueError(f"Invalid cron expression: {cron_expression}") from e
38
+
39
+ next_time: datetime = cron.get_next(datetime)
40
+ # croniter returns tz-aware datetime in the same tz as input
41
+ return next_time.astimezone(timezone.utc)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+ import functools
6
+ import logging
7
+
8
+ from django.db import transaction
9
+ from django.utils import timezone
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def exactly_once(func: Callable[..., Any]) -> Callable[..., Any]:
15
+ """Decorator ensuring a scheduled task runs at most once per invocation.
16
+
17
+ When the scheduler creates a ``TaskExecution`` row and passes its ID via
18
+ the ``_periodic_tasks_execution_id`` keyword argument, this decorator will:
19
+
20
+ 1. Pop ``_periodic_tasks_execution_id`` from kwargs.
21
+ 2. Lock the ``TaskExecution`` row with ``SELECT FOR UPDATE``.
22
+ 3. Run the wrapped function only if the row's status is ``PENDING``.
23
+ 4. Mark the row ``COMPLETED`` on success.
24
+
25
+ If ``_periodic_tasks_execution_id`` is absent (e.g. manual invocation), the wrapped
26
+ function runs normally without any execution-permit logic.
27
+ """
28
+
29
+ @functools.wraps(func)
30
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
31
+ execution_id: str | None = kwargs.pop("_periodic_tasks_execution_id", None)
32
+
33
+ if execution_id is None:
34
+ return func(*args, **kwargs)
35
+
36
+ from django_periodic_tasks.models import (
37
+ TaskExecution, # Avoid AppRegistryNotReady
38
+ )
39
+
40
+ with transaction.atomic():
41
+ execution = TaskExecution.objects.select_for_update().filter(id=execution_id, status=TaskExecution.Status.PENDING).first()
42
+
43
+ if execution is None:
44
+ logger.warning(
45
+ "TaskExecution %s not found or not PENDING, skipping",
46
+ execution_id,
47
+ )
48
+ return None
49
+
50
+ result = func(*args, **kwargs)
51
+
52
+ execution.status = TaskExecution.Status.COMPLETED
53
+ execution.completed_at = timezone.now()
54
+ execution.save(update_fields=["status", "completed_at"])
55
+
56
+ return result
57
+
58
+ wrapper._exactly_once = True # type: ignore[attr-defined]
59
+ return wrapper
File without changes
File without changes
@@ -0,0 +1,60 @@
1
+ from argparse import ArgumentParser
2
+ from types import FrameType
3
+ import logging
4
+ import signal
5
+
6
+ from django.conf import settings
7
+ from django.core.management.base import BaseCommand
8
+
9
+ from django_periodic_tasks.scheduler import PeriodicTaskScheduler
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Command(BaseCommand):
15
+ """Run the periodic task scheduler as a standalone process.
16
+
17
+ This command starts the scheduler loop in the main thread (blocking).
18
+ It syncs code-defined schedules to the database, then repeatedly checks for
19
+ due tasks and enqueues them. Use this when running the scheduler separately
20
+ from the task worker, or when using a non-database task backend.
21
+ """
22
+
23
+ help = "Run the periodic task scheduler (without a task worker)"
24
+
25
+ def add_arguments(self, parser: ArgumentParser) -> None:
26
+ default_interval: int = getattr(settings, "PERIODIC_TASKS_SCHEDULER_INTERVAL", 15)
27
+ parser.add_argument(
28
+ "--interval",
29
+ type=int,
30
+ default=default_interval,
31
+ help="Interval in seconds between scheduler ticks (default: %(default)s)",
32
+ )
33
+
34
+ def handle(self, *, interval: int, verbosity: int, **options: object) -> None:
35
+ self._configure_logging(verbosity)
36
+
37
+ scheduler = PeriodicTaskScheduler(interval=interval)
38
+ logger.info("Starting periodic task scheduler (interval=%ds)", interval)
39
+
40
+ def shutdown(signum: int, frame: FrameType | None) -> None:
41
+ logger.info("Received %s, stopping scheduler...", signal.strsignal(signum))
42
+ scheduler.stop()
43
+
44
+ signal.signal(signal.SIGINT, shutdown)
45
+ signal.signal(signal.SIGTERM, shutdown)
46
+
47
+ # Run in the main thread (blocking)
48
+ scheduler.run()
49
+
50
+ def _configure_logging(self, verbosity: int) -> None:
51
+ pkg_logger = logging.getLogger("django_periodic_tasks")
52
+ if verbosity == 0:
53
+ pkg_logger.setLevel(logging.CRITICAL)
54
+ elif verbosity == 1:
55
+ pkg_logger.setLevel(logging.INFO)
56
+ else:
57
+ pkg_logger.setLevel(logging.DEBUG)
58
+
59
+ if not pkg_logger.hasHandlers():
60
+ pkg_logger.addHandler(logging.StreamHandler(self.stdout))
@@ -0,0 +1,37 @@
1
+ # Generated by Django 6.0.2 on 2026-02-05 19:03
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ initial = True
8
+
9
+ dependencies = []
10
+
11
+ operations = [
12
+ migrations.CreateModel(
13
+ name="ScheduledTask",
14
+ fields=[
15
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16
+ ("name", models.CharField(max_length=200, unique=True)),
17
+ ("task_path", models.CharField(max_length=200)),
18
+ ("cron_expression", models.CharField(max_length=200)),
19
+ ("timezone", models.CharField(default="UTC", max_length=63)),
20
+ ("args", models.JSONField(blank=True, default=list)),
21
+ ("kwargs", models.JSONField(blank=True, default=dict)),
22
+ ("source", models.CharField(choices=[("code", "Code"), ("database", "Database")], default="database", max_length=20)),
23
+ ("enabled", models.BooleanField(default=True)),
24
+ ("last_run_at", models.DateTimeField(blank=True, null=True)),
25
+ ("next_run_at", models.DateTimeField(blank=True, db_index=True, null=True)),
26
+ ("total_run_count", models.PositiveIntegerField(default=0)),
27
+ ("queue_name", models.CharField(blank=True, default="default", max_length=32)),
28
+ ("priority", models.IntegerField(default=0)),
29
+ ("backend", models.CharField(blank=True, default="default", max_length=32)),
30
+ ("created_at", models.DateTimeField(auto_now_add=True)),
31
+ ("updated_at", models.DateTimeField(auto_now=True)),
32
+ ],
33
+ options={
34
+ "indexes": [models.Index(condition=models.Q(("enabled", True)), fields=["next_run_at"], name="periodic_due_tasks_idx")],
35
+ },
36
+ ),
37
+ ]
@@ -0,0 +1,17 @@
1
+ # Generated by Django 6.0.2 on 2026-02-05 21:05
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("django_periodic_tasks", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterField(
13
+ model_name="scheduledtask",
14
+ name="next_run_at",
15
+ field=models.DateTimeField(blank=True, null=True),
16
+ ),
17
+ ]
@@ -0,0 +1,31 @@
1
+ # Generated by Django 6.0.2 on 2026-02-05 21:09
2
+
3
+ import uuid
4
+
5
+ from django.db import migrations, models
6
+ import django.db.models.deletion
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("django_periodic_tasks", "0002_remove_redundant_next_run_at_db_index"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="TaskExecution",
17
+ fields=[
18
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
19
+ ("status", models.CharField(choices=[("pending", "Pending"), ("completed", "Completed")], default="pending", max_length=20)),
20
+ ("created_at", models.DateTimeField(auto_now_add=True)),
21
+ ("completed_at", models.DateTimeField(blank=True, null=True)),
22
+ (
23
+ "scheduled_task",
24
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="executions", to="django_periodic_tasks.scheduledtask"),
25
+ ),
26
+ ],
27
+ options={
28
+ "indexes": [models.Index(condition=models.Q(("status", "pending")), fields=["status"], name="periodic_pending_exec_idx")],
29
+ },
30
+ ),
31
+ ]
File without changes
@@ -0,0 +1,146 @@
1
+ from typing import Any
2
+ from zoneinfo import ZoneInfo
3
+ import uuid
4
+
5
+ from django.core.exceptions import ValidationError
6
+ from django.db import models
7
+
8
+ from django_periodic_tasks.cron import compute_next_run_at, validate_cron_expression
9
+
10
+
11
+ class ScheduledTask(models.Model):
12
+ """A persistent record of a periodic task and its cron schedule.
13
+
14
+ Each row represents one scheduled task. The scheduler queries this table on
15
+ every tick to find tasks whose ``next_run_at`` has passed, then enqueues
16
+ them via django-tasks.
17
+
18
+ Tasks can originate from two sources (see :class:`Source`):
19
+
20
+ * **Code-defined** — registered with :func:`~django_periodic_tasks.registry.scheduled_task`
21
+ and synced to the database on scheduler startup.
22
+ * **Database-defined** — created manually through the Django admin.
23
+ """
24
+
25
+ class Source(models.TextChoices):
26
+ """Where a scheduled task definition comes from.
27
+
28
+ ``CODE`` schedules are managed by the codebase and synced automatically.
29
+ ``DATABASE`` schedules are managed by operators through the Django admin.
30
+ """
31
+
32
+ CODE = "code", "Code"
33
+ DATABASE = "database", "Database"
34
+
35
+ # Identity
36
+ name = models.CharField(max_length=200, unique=True)
37
+ task_path = models.CharField(max_length=200)
38
+
39
+ # Schedule
40
+ cron_expression = models.CharField(max_length=200)
41
+ timezone = models.CharField(max_length=63, default="UTC")
42
+
43
+ # Arguments (passed to task.enqueue())
44
+ args = models.JSONField(default=list, blank=True)
45
+ kwargs = models.JSONField(default=dict, blank=True)
46
+
47
+ # Source & Status
48
+ source = models.CharField(max_length=20, choices=Source.choices, default=Source.DATABASE)
49
+ enabled = models.BooleanField(default=True)
50
+
51
+ # Execution tracking
52
+ last_run_at = models.DateTimeField(null=True, blank=True)
53
+ next_run_at = models.DateTimeField(null=True, blank=True)
54
+ total_run_count = models.PositiveIntegerField(default=0)
55
+
56
+ # Task options (passed to task.using())
57
+ queue_name = models.CharField(max_length=32, default="default", blank=True)
58
+ priority = models.IntegerField(default=0)
59
+ backend = models.CharField(max_length=32, default="default", blank=True)
60
+
61
+ # Metadata
62
+ created_at = models.DateTimeField(auto_now_add=True)
63
+ updated_at = models.DateTimeField(auto_now=True)
64
+
65
+ class Meta:
66
+ indexes = [
67
+ models.Index(
68
+ fields=["next_run_at"],
69
+ condition=models.Q(enabled=True),
70
+ name="periodic_due_tasks_idx",
71
+ ),
72
+ ]
73
+
74
+ def __str__(self) -> str:
75
+ return f"{self.name} ({self.cron_expression})"
76
+
77
+ def clean(self) -> None:
78
+ errors: dict[str, str] = {}
79
+ if not validate_cron_expression(self.cron_expression):
80
+ errors["cron_expression"] = f"Invalid cron expression: {self.cron_expression}"
81
+ try:
82
+ ZoneInfo(self.timezone)
83
+ except (KeyError, ValueError):
84
+ errors["timezone"] = f"Invalid timezone: {self.timezone}"
85
+ if "." not in self.task_path:
86
+ errors["task_path"] = "task_path must be a dotted module path (e.g. 'myapp.tasks.my_task')"
87
+ if errors:
88
+ raise ValidationError(errors)
89
+
90
+ def save(self, *args: Any, **kwargs: Any) -> None:
91
+ original_next_run_at = self.next_run_at
92
+
93
+ if not self.enabled:
94
+ self.next_run_at = None
95
+ elif self.next_run_at is None:
96
+ self.next_run_at = compute_next_run_at(self.cron_expression, self.timezone)
97
+
98
+ raw_update_fields: list[str] | None = kwargs.get("update_fields")
99
+ if raw_update_fields is not None:
100
+ fields = set(raw_update_fields)
101
+ # Always include updated_at so auto_now fires
102
+ fields.add("updated_at")
103
+ # Include next_run_at if save() modified it
104
+ if self.next_run_at != original_next_run_at:
105
+ fields.add("next_run_at")
106
+ kwargs["update_fields"] = list(fields)
107
+
108
+ super().save(*args, **kwargs)
109
+
110
+
111
+ class TaskExecution(models.Model):
112
+ """An execution permit for a single scheduled task invocation.
113
+
114
+ Used by the ``@exactly_once`` decorator to ensure a task runs at most once
115
+ per scheduled invocation, even with non-transactional backends (e.g. Redis/RQ).
116
+ """
117
+
118
+ class Status(models.TextChoices):
119
+ PENDING = "pending", "Pending"
120
+ COMPLETED = "completed", "Completed"
121
+
122
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
123
+ scheduled_task = models.ForeignKey(
124
+ ScheduledTask,
125
+ on_delete=models.CASCADE,
126
+ related_name="executions",
127
+ )
128
+ status = models.CharField(
129
+ max_length=20,
130
+ choices=Status.choices,
131
+ default=Status.PENDING,
132
+ )
133
+ created_at = models.DateTimeField(auto_now_add=True)
134
+ completed_at = models.DateTimeField(null=True, blank=True)
135
+
136
+ class Meta:
137
+ indexes = [
138
+ models.Index(
139
+ fields=["status"],
140
+ condition=models.Q(status="pending"),
141
+ name="periodic_pending_exec_idx",
142
+ ),
143
+ ]
144
+
145
+ def __str__(self) -> str:
146
+ return f"{self.scheduled_task.name} [{self.status}]"
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from django_periodic_tasks.cron import validate_cron_expression
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ScheduleEntry:
11
+ """An immutable record describing a single scheduled task.
12
+
13
+ Each entry holds the task object, its cron schedule, and the options that
14
+ will be forwarded to ``task.using()`` / ``task.enqueue()`` at execution time.
15
+
16
+ Attributes:
17
+ task: The django-tasks ``Task`` object to enqueue.
18
+ cron_expression: A standard 5-field cron expression (e.g. ``"*/15 * * * *"``).
19
+ name: Unique name for this schedule (used as the DB primary key).
20
+ timezone: IANA timezone name used for cron matching (default ``"UTC"``).
21
+ args: Positional arguments passed to ``task.enqueue()``.
22
+ kwargs: Keyword arguments passed to ``task.enqueue()``.
23
+ queue_name: Task queue name passed to ``task.using()``.
24
+ priority: Task priority passed to ``task.using()``.
25
+ backend: Task backend name passed to ``task.using()``.
26
+ """
27
+
28
+ task: Any
29
+ cron_expression: str
30
+ name: str
31
+ timezone: str = "UTC"
32
+ args: list[Any] = field(default_factory=list)
33
+ kwargs: dict[str, Any] = field(default_factory=dict)
34
+ queue_name: str = "default"
35
+ priority: int = 0
36
+ backend: str = "default"
37
+
38
+
39
+ class ScheduleRegistry:
40
+ """Singleton registry for code-defined schedules."""
41
+
42
+ def __init__(self) -> None:
43
+ self._entries: dict[str, ScheduleEntry] = {}
44
+
45
+ def register(
46
+ self,
47
+ task: Any,
48
+ *,
49
+ cron: str,
50
+ name: str,
51
+ timezone: str = "UTC",
52
+ args: list[Any] | None = None,
53
+ kwargs: dict[str, Any] | None = None,
54
+ queue_name: str = "default",
55
+ priority: int = 0,
56
+ backend: str = "default",
57
+ ) -> None:
58
+ """Register a task with the given cron schedule.
59
+
60
+ Args:
61
+ task: A django-tasks ``Task`` object.
62
+ cron: A 5-field cron expression (e.g. ``"0 */6 * * *"``).
63
+ name: Unique name for this schedule.
64
+ timezone: IANA timezone for cron matching (default ``"UTC"``).
65
+ args: Positional arguments for ``task.enqueue()``.
66
+ kwargs: Keyword arguments for ``task.enqueue()``.
67
+ queue_name: Queue name for ``task.using()``.
68
+ priority: Priority for ``task.using()``.
69
+ backend: Backend name for ``task.using()``.
70
+
71
+ Raises:
72
+ ValueError: If the cron expression is invalid or the name is already registered.
73
+ """
74
+ if not validate_cron_expression(cron):
75
+ raise ValueError(f"Invalid cron expression: {cron}")
76
+ if name in self._entries:
77
+ raise ValueError(f"Schedule with name '{name}' is already registered")
78
+ self._entries[name] = ScheduleEntry(
79
+ task=task,
80
+ cron_expression=cron,
81
+ name=name,
82
+ timezone=timezone,
83
+ args=args or [],
84
+ kwargs=kwargs or {},
85
+ queue_name=queue_name,
86
+ priority=priority,
87
+ backend=backend,
88
+ )
89
+
90
+ def get_entries(self) -> dict[str, ScheduleEntry]:
91
+ """Return a copy of all registered schedule entries, keyed by name."""
92
+ return dict(self._entries)
93
+
94
+
95
+ schedule_registry = ScheduleRegistry()
96
+
97
+
98
+ def scheduled_task(
99
+ *,
100
+ cron: str,
101
+ name: str | None = None,
102
+ registry: ScheduleRegistry | None = None,
103
+ **kwargs: Any,
104
+ ) -> Any:
105
+ """Decorator that registers a django-tasks ``Task`` with the schedule registry.
106
+
107
+ Apply this decorator **after** ``@task()`` to register the task for periodic
108
+ execution::
109
+
110
+ @scheduled_task(cron="*/5 * * * *")
111
+ @task()
112
+ def send_digest(user_id: int) -> None:
113
+ ...
114
+
115
+ Args:
116
+ cron: A 5-field cron expression (e.g. ``"0 8 * * 1-5"``).
117
+ name: Unique schedule name. Defaults to the task's ``module_path``.
118
+ registry: An alternate ``ScheduleRegistry`` instance (defaults to the
119
+ global ``schedule_registry``).
120
+ **kwargs: Extra options forwarded to
121
+ :meth:`ScheduleRegistry.register` (``timezone``, ``args``,
122
+ ``kwargs``, ``queue_name``, ``priority``, ``backend``).
123
+ """
124
+ target_registry = registry or schedule_registry
125
+
126
+ def decorator(task_obj: Any) -> Any:
127
+ actual_name = name or task_obj.module_path
128
+ target_registry.register(task_obj, cron=cron, name=actual_name, **kwargs)
129
+ return task_obj
130
+
131
+ return decorator
@@ -0,0 +1,200 @@
1
+ from datetime import timedelta
2
+ import logging
3
+ import threading
4
+
5
+ from django.db import transaction
6
+ from django.db.models import F
7
+ from django.utils import timezone
8
+
9
+ from django_periodic_tasks.cron import compute_next_run_at
10
+ from django_periodic_tasks.models import ScheduledTask, TaskExecution
11
+ from django_periodic_tasks.sync import sync_code_schedules
12
+ from django_periodic_tasks.task_resolver import resolve_task
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PeriodicTaskScheduler(threading.Thread):
18
+ """Daemon thread that periodically enqueues due scheduled tasks.
19
+
20
+ On each tick the scheduler:
21
+
22
+ 1. Queries :class:`~django_periodic_tasks.models.ScheduledTask` for rows
23
+ whose ``next_run_at ≤ now`` and ``enabled = True``.
24
+ 2. Locks those rows with ``SELECT FOR UPDATE SKIP LOCKED`` so multiple
25
+ scheduler instances can run safely in parallel.
26
+ 3. Resolves each task path to a django-tasks ``Task`` object and calls
27
+ ``task.using(...).enqueue(...)``.
28
+ 4. Updates ``last_run_at``, ``next_run_at``, and ``total_run_count``.
29
+
30
+ Args:
31
+ interval: Seconds between scheduler ticks (default ``15``).
32
+ """
33
+
34
+ daemon = True
35
+
36
+ def __init__(self, interval: int = 15) -> None:
37
+ if interval <= 0:
38
+ raise ValueError(f"interval must be positive, got {interval}")
39
+ super().__init__(name="periodic-task-scheduler")
40
+ self.interval = interval
41
+ self._stop_event = threading.Event()
42
+
43
+ def run(self) -> None:
44
+ """Start the scheduler loop.
45
+
46
+ Syncs code-defined schedules to the database, then enters the
47
+ tick-sleep loop until :meth:`stop` is called.
48
+ """
49
+ logger.info("Periodic task scheduler starting (interval=%ds)", self.interval)
50
+ try:
51
+ sync_code_schedules()
52
+ except Exception:
53
+ logger.exception("Failed to sync code schedules on startup")
54
+ while not self._stop_event.is_set():
55
+ try:
56
+ self.tick()
57
+ except Exception:
58
+ logger.exception("Scheduler tick failed")
59
+ self._stop_event.wait(self.interval)
60
+ logger.info("Periodic task scheduler stopped")
61
+
62
+ def tick(self) -> None:
63
+ """Single scheduler tick: find and enqueue due tasks.
64
+
65
+ All due tasks are locked with ``SELECT FOR UPDATE SKIP LOCKED`` for the
66
+ duration of the tick so that concurrent scheduler instances never
67
+ enqueue the same task twice.
68
+ """
69
+ try:
70
+ self._cleanup_stale_executions()
71
+ except Exception:
72
+ logger.exception("Stale execution cleanup failed")
73
+
74
+ try:
75
+ self._delete_old_executions()
76
+ except Exception:
77
+ logger.exception("Old execution cleanup failed")
78
+
79
+ now = timezone.now()
80
+
81
+ with transaction.atomic():
82
+ due_tasks = list(
83
+ ScheduledTask.objects.filter(
84
+ enabled=True,
85
+ next_run_at__lte=now,
86
+ ).select_for_update(skip_locked=True)
87
+ )
88
+
89
+ for st in due_tasks:
90
+ try:
91
+ with transaction.atomic():
92
+ self._process_task(st)
93
+ except Exception:
94
+ logger.exception("Failed to enqueue scheduled task id=%s", st.id)
95
+ try:
96
+ st.next_run_at = compute_next_run_at(st.cron_expression, st.timezone)
97
+ st.save(update_fields=["next_run_at"])
98
+ except Exception:
99
+ logger.exception("Failed to advance next_run_at for task id=%s", st.id)
100
+
101
+ def _process_task(self, st: ScheduledTask) -> None:
102
+ task_obj = resolve_task(st.task_path)
103
+ configured = task_obj.using(
104
+ queue_name=st.queue_name,
105
+ priority=st.priority,
106
+ backend=st.backend,
107
+ )
108
+
109
+ is_exactly_once = getattr(task_obj.func, "_exactly_once", False)
110
+
111
+ if is_exactly_once:
112
+ execution = TaskExecution.objects.create(scheduled_task=st)
113
+ enqueue_kwargs = {**st.kwargs, "_periodic_tasks_execution_id": str(execution.id)}
114
+
115
+ def _deferred_enqueue() -> None:
116
+ configured.enqueue(*st.args, **enqueue_kwargs)
117
+
118
+ transaction.on_commit(_deferred_enqueue)
119
+ else:
120
+ configured.enqueue(*st.args, **st.kwargs)
121
+
122
+ st.last_run_at = timezone.now()
123
+ st.next_run_at = compute_next_run_at(st.cron_expression, st.timezone)
124
+ st.total_run_count = F("total_run_count") + 1
125
+ st.save(update_fields=["last_run_at", "next_run_at", "total_run_count"])
126
+
127
+ logger.info("Enqueued scheduled task: %s", st.name)
128
+
129
+ def _cleanup_stale_executions(self) -> None:
130
+ """Re-enqueue stale PENDING TaskExecutions that were never delivered.
131
+
132
+ A TaskExecution can become stale if the scheduler committed the row but
133
+ the ``on_commit`` callback that enqueues the task never fired (e.g. the
134
+ process crashed, the connection was reset, etc.).
135
+
136
+ Direct enqueue (not ``on_commit``) is safe here because the
137
+ TaskExecution row was committed in a prior tick's transaction and is
138
+ already visible to workers.
139
+ """
140
+ threshold = timezone.now() - timedelta(seconds=max(60, 2 * self.interval))
141
+
142
+ with transaction.atomic():
143
+ stale = list(
144
+ TaskExecution.objects.filter(
145
+ status=TaskExecution.Status.PENDING,
146
+ created_at__lt=threshold,
147
+ scheduled_task__enabled=True,
148
+ )
149
+ .select_related("scheduled_task")
150
+ .select_for_update(skip_locked=True)
151
+ )
152
+
153
+ for execution in stale:
154
+ try:
155
+ st = execution.scheduled_task
156
+ task_obj = resolve_task(st.task_path)
157
+ configured = task_obj.using(
158
+ queue_name=st.queue_name,
159
+ priority=st.priority,
160
+ backend=st.backend,
161
+ )
162
+ enqueue_kwargs = {
163
+ **st.kwargs,
164
+ "_periodic_tasks_execution_id": str(execution.id),
165
+ }
166
+ configured.enqueue(*st.args, **enqueue_kwargs)
167
+ logger.info(
168
+ "Re-enqueued stale execution %s for task %s",
169
+ execution.id,
170
+ st.name,
171
+ )
172
+ except Exception:
173
+ logger.exception(
174
+ "Failed to re-enqueue stale execution %s",
175
+ execution.id,
176
+ )
177
+
178
+ def _delete_old_executions(self) -> None:
179
+ """Bulk-delete non-PENDING TaskExecution rows older than 24 hours.
180
+
181
+ COMPLETED rows have no ongoing purpose once workers have finished
182
+ processing them. PENDING rows are preserved because they may still be
183
+ awaiting delivery or re-enqueue by stale cleanup.
184
+ """
185
+ threshold = timezone.now() - timedelta(hours=24)
186
+ deleted, _ = (
187
+ TaskExecution.objects.filter(
188
+ created_at__lt=threshold,
189
+ )
190
+ .exclude(
191
+ status=TaskExecution.Status.PENDING,
192
+ )
193
+ .delete()
194
+ )
195
+ if deleted:
196
+ logger.info("Deleted %d old task execution(s)", deleted)
197
+
198
+ def stop(self) -> None:
199
+ """Signal the scheduler to stop after the current tick completes."""
200
+ self._stop_event.set()
@@ -0,0 +1,57 @@
1
+ import logging
2
+
3
+ from django.db import transaction
4
+
5
+ from django_periodic_tasks.models import ScheduledTask
6
+ from django_periodic_tasks.registry import ScheduleRegistry, schedule_registry
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def sync_code_schedules(registry: ScheduleRegistry | None = None) -> None:
12
+ """Sync in-memory registry to ScheduledTask DB records.
13
+
14
+ - Creates new ScheduledTask for new code entries
15
+ - Updates cron_expression/task_path/options if changed
16
+ - Disables stale code entries (in registry previously, now removed)
17
+ - Never touches source=DATABASE records
18
+ """
19
+ target_registry = registry or schedule_registry
20
+ entries = target_registry.get_entries()
21
+
22
+ with transaction.atomic():
23
+ # Create or update code entries
24
+ seen_names: set[str] = set()
25
+ for name, entry in entries.items():
26
+ seen_names.add(name)
27
+ defaults = {
28
+ "task_path": entry.task.module_path,
29
+ "cron_expression": entry.cron_expression,
30
+ "timezone": entry.timezone,
31
+ "args": entry.args,
32
+ "kwargs": entry.kwargs,
33
+ "queue_name": entry.queue_name,
34
+ "priority": entry.priority,
35
+ "backend": entry.backend,
36
+ "source": ScheduledTask.Source.CODE,
37
+ "enabled": True,
38
+ "next_run_at": None,
39
+ }
40
+ obj, created = ScheduledTask.objects.update_or_create(
41
+ name=name,
42
+ defaults=defaults,
43
+ )
44
+ if created:
45
+ logger.info("Created code schedule: %s", name)
46
+ else:
47
+ logger.debug("Updated code schedule: %s", name)
48
+
49
+ # Disable code-defined entries that are no longer in the registry
50
+ stale = ScheduledTask.objects.filter(
51
+ source=ScheduledTask.Source.CODE,
52
+ enabled=True,
53
+ ).exclude(name__in=seen_names)
54
+
55
+ stale_count = stale.update(enabled=False, next_run_at=None)
56
+ if stale_count:
57
+ logger.info("Disabled %d stale code schedule(s)", stale_count)
@@ -0,0 +1,22 @@
1
+ from importlib import import_module
2
+ from typing import Any
3
+
4
+ from django_tasks.base import Task
5
+
6
+
7
+ def resolve_task(task_path: str) -> Task[..., Any]:
8
+ """Import and return a django-tasks Task object from its dotted module path.
9
+
10
+ The task_path should be a dotted path like "myapp.tasks.my_task".
11
+ """
12
+ module_path, _, attr_name = task_path.rpartition(".")
13
+ if not module_path:
14
+ raise ImportError(f"Invalid task path: {task_path}")
15
+
16
+ module = import_module(module_path)
17
+ obj = getattr(module, attr_name)
18
+
19
+ if not isinstance(obj, Task):
20
+ raise TypeError(f"{task_path} is not a django-tasks Task instance (got {type(obj).__name__})")
21
+
22
+ return obj
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-periodic-tasks
3
+ Version: 0.1.0a3
4
+ Summary: Periodic/cron task scheduling for django-tasks. Backend-agnostic replacement for celery-beat.
5
+ Project-URL: Homepage, https://gitlab.com/thelabnyc/django-periodic-tasks
6
+ Project-URL: Repository, https://gitlab.com/thelabnyc/django-periodic-tasks
7
+ Author-email: thelab <thelabdev@thelab.co>
8
+ License: ISC
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.13
11
+ Requires-Dist: croniter>=1.0
12
+ Requires-Dist: django-tasks>=0.7
13
+ Requires-Dist: django>=5.2
14
+ Provides-Extra: rq
15
+ Requires-Dist: django-rq; extra == 'rq'
16
+ Requires-Dist: django-tasks[rq]; extra == 'rq'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # django-periodic-tasks
20
+
21
+ Periodic/cron task scheduling for [django-tasks](https://github.com/RealOrangeOne/django-tasks). Backend-agnostic replacement for celery-beat + django-celery-beat.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install django-periodic-tasks
27
+ ```
28
+
29
+ Add to `INSTALLED_APPS`:
30
+
31
+ ```python
32
+ INSTALLED_APPS = [
33
+ ...
34
+ "django_periodic_tasks",
35
+ ]
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### Define scheduled tasks
41
+
42
+ ```python
43
+ from django_tasks import task
44
+ from django_periodic_tasks import scheduled_task
45
+
46
+ @scheduled_task(cron="0 5 * * *", name="daily-report")
47
+ @task()
48
+ def daily_report() -> None:
49
+ ...
50
+ ```
51
+
52
+ ### Run the scheduler
53
+
54
+ Enable the autostart setting so the scheduler runs as a daemon thread inside your Django process:
55
+
56
+ ```python
57
+ # settings.py
58
+ PERIODIC_TASKS_AUTOSTART = True
59
+ ```
60
+
61
+ Or run it as a standalone process:
62
+
63
+ ```bash
64
+ python manage.py run_scheduler
65
+ ```
@@ -0,0 +1,21 @@
1
+ django_periodic_tasks/__init__.py,sha256=5cgybxXwf98hZnNAOX6DYHeeYuwzC9jgwJZU0XGUOeQ,316
2
+ django_periodic_tasks/admin.py,sha256=87o6_Z-f79ZhdAoTpFWdVhV9rZQQb-k17FGR_phHPuw,2373
3
+ django_periodic_tasks/apps.py,sha256=KezRzFjYSleKustuoiG0KhFy5N2uUgmi8qrx5bqcN6k,1159
4
+ django_periodic_tasks/cron.py,sha256=PHVlX_YyzALgpYYBqWkKdvEm0uE0lnBTM5TUDcYLfS0,1332
5
+ django_periodic_tasks/decorators.py,sha256=GhkNUqlPubUj6ZhS4Nf1FsTUMZaihNyD8wrTFOzbiKU,2024
6
+ django_periodic_tasks/models.py,sha256=qJEwSnBJ-L54qr0Dl9gvHWZ0h6k2H6x5nMS7f9O2L3k,5199
7
+ django_periodic_tasks/registry.py,sha256=sS897SBu1RP7JVQDNkMZQfahhCU_9ZpV6qHmdwvKiik,4532
8
+ django_periodic_tasks/scheduler.py,sha256=zl-LsAmO9Io2rZG1P4WrG-is3yGxAX0430Dmd0zVEBs,7600
9
+ django_periodic_tasks/sync.py,sha256=EfvcZlP5-199uxIMwJRdCnXzObQohHLYR4W819d5i5k,2118
10
+ django_periodic_tasks/task_resolver.py,sha256=OMlH3s9b4rerLOItm3KdazWLZPiSTbjHTZlWpd9qt_8,684
11
+ django_periodic_tasks/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ django_periodic_tasks/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ django_periodic_tasks/management/commands/run_scheduler.py,sha256=kCVBi10KLHb_8iqizT3mlfW_cuQOLpsrF-VowdXkkAM,2194
14
+ django_periodic_tasks/migrations/0001_initial.py,sha256=h_AfdKIG9f-XmDZiqR_zzgtr5Mcy8CdbFrNUQOzSePE,1873
15
+ django_periodic_tasks/migrations/0002_remove_redundant_next_run_at_db_index.py,sha256=RPWdcpU7wkQd-47oLbr14q6JgfyaqfOkys2o8KmocoU,412
16
+ django_periodic_tasks/migrations/0003_add_task_execution.py,sha256=3cZrhQmBR2oIaIkTeMYGoztuPyO6rdh84wOlex5fUzs,1235
17
+ django_periodic_tasks/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ django_periodic_tasks-0.1.0a3.dist-info/METADATA,sha256=pee1VhsmKl-W0Ani-ynwztXEBkXECXx3ieuh7EIzBtE,1505
19
+ django_periodic_tasks-0.1.0a3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
+ django_periodic_tasks-0.1.0a3.dist-info/licenses/LICENSE,sha256=XdK5EYewCzTpCGUgHO6aUH6TlvzAdvQrtv1Xacd7OIQ,738
21
+ django_periodic_tasks-0.1.0a3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025, thelab
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.