-
Notifications
You must be signed in to change notification settings - Fork 88
Add cron-expression scheduling (RunMode.CRON) #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # Generated manually for raw cron scheduling | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("core", "0038_plugin_ownership"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| # Add the raw cron expression field | ||
| migrations.AddField( | ||
| model_name="scriptschedule", | ||
| name="cron_expression", | ||
| field=models.CharField( | ||
| blank=True, | ||
| default="", | ||
| help_text=( | ||
| 'Raw cron expression, e.g. "0 9 * * 1-5" ' | ||
| "(minute hour day-of-month month day-of-week)" | ||
| ), | ||
| max_length=100, | ||
| ), | ||
| ), | ||
| # Update run_mode choices to include cron | ||
| migrations.AlterField( | ||
| model_name="scriptschedule", | ||
| name="run_mode", | ||
| field=models.CharField( | ||
| choices=[ | ||
| ("manual", "Manual"), | ||
| ("interval", "Interval"), | ||
| ("daily", "Daily"), | ||
| ("weekly", "Weekly"), | ||
| ("monthly", "Monthly"), | ||
| ("cron", "Cron expression"), | ||
| ], | ||
| default="manual", | ||
| max_length=20, | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,8 @@ def sync_schedule(cls, script_schedule) -> list[int]: | |
| q_schedule_ids = cls._create_weekly_schedules(script_schedule) | ||
| elif script_schedule.run_mode == ScriptSchedule.RunMode.MONTHLY: | ||
| q_schedule_ids = cls._create_monthly_schedules(script_schedule) | ||
| elif script_schedule.run_mode == ScriptSchedule.RunMode.CRON: | ||
| q_schedule_ids = cls._create_cron_schedule(script_schedule) | ||
|
|
||
| # Update the ScriptSchedule with new IDs and next_run | ||
| script_schedule.q_schedule_ids = q_schedule_ids | ||
|
|
@@ -196,6 +198,39 @@ def _create_monthly_schedules(cls, script_schedule) -> list[int]: | |
|
|
||
| return q_schedule_ids | ||
|
|
||
| @classmethod | ||
| def _create_cron_schedule(cls, script_schedule) -> list[int]: | ||
| """ | ||
| Create a single CRON type django-q2 schedule from a raw cron expression. | ||
|
|
||
| The expression is passed straight through to django-q2, which uses | ||
| croniter to compute run times. It is interpreted in the cluster's | ||
| configured timezone (Django ``TIME_ZONE`` / ``Q_CLUSTER`` timezone), | ||
| consistent with the daily/weekly/monthly modes. | ||
| """ | ||
| cron_expr = (script_schedule.cron_expression or "").strip() | ||
| if not cron_expr: | ||
| logger.warning( | ||
| f"Cron schedule for script {script_schedule.script.name} has no " | ||
| f"expression - skipping" | ||
| ) | ||
| return [] | ||
|
|
||
| q_schedule = QSchedule.objects.create( | ||
| name=f"pyrunner-{script_schedule.script.id}-cron", | ||
| func=cls.TASK_FUNC, | ||
| args=f"'{script_schedule.script.id}'", | ||
| schedule_type=QSchedule.CRON, | ||
| cron=cron_expr, | ||
| repeats=-1, # Run forever | ||
| next_run=timezone.now(), | ||
| ) | ||
| logger.info( | ||
| f"Created cron schedule {q_schedule.id} for script " | ||
| f"{script_schedule.script.name} ('{cron_expr}')" | ||
| ) | ||
| return [q_schedule.id] | ||
|
|
||
| @classmethod | ||
| def delete_q_schedules(cls, script_schedule) -> int: | ||
| """Delete all django-q2 schedules associated with a ScriptSchedule.""" | ||
|
|
@@ -323,8 +358,70 @@ def _calculate_next_run(cls, script_schedule) -> Optional[datetime]: | |
|
|
||
| return min(candidates) if candidates else None | ||
|
|
||
| elif script_schedule.run_mode == ScriptSchedule.RunMode.CRON: | ||
| cron_expr = (script_schedule.cron_expression or "").strip() | ||
| if not cron_expr: | ||
| return None | ||
| try: | ||
| from croniter import croniter | ||
|
|
||
| return croniter(cron_expr, now).get_next(datetime) | ||
| except (ValueError, KeyError) as exc: | ||
| logger.warning( | ||
| f"Could not compute next run for cron '{cron_expr}': {exc}" | ||
| ) | ||
| return None | ||
|
|
||
| return None | ||
|
Comment on lines
+361
to
375
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Use local time for CRON next-run computations.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| @staticmethod | ||
| def validate_cron_expression(expression: str) -> tuple[bool, Optional[str]]: | ||
| """ | ||
| Validate a raw 5-field cron expression. | ||
|
|
||
| Returns ``(is_valid, error_message)``. ``error_message`` is None when | ||
| the expression is valid. | ||
| """ | ||
| expr = (expression or "").strip() | ||
| if not expr: | ||
| return False, "Cron expression is required." | ||
|
|
||
| # django-q2 / croniter operate on the standard 5-field cron format. | ||
| # Reject 6-field (seconds) or named @-shortcuts to avoid surprising | ||
| # behaviour, since those are not what django-q2's scheduler expects. | ||
| if expr.startswith("@"): | ||
| return False, "Named shortcuts like '@daily' are not supported - use a 5-field expression." | ||
| if len(expr.split()) != 5: | ||
| return False, "Expected 5 fields: minute hour day-of-month month day-of-week." | ||
|
|
||
| try: | ||
| from croniter import croniter | ||
|
|
||
| if not croniter.is_valid(expr): | ||
| return False, "Not a valid cron expression." | ||
| except ImportError: # pragma: no cover - croniter ships with django-q2 | ||
| return False, "Cron validation is unavailable (croniter not installed)." | ||
|
|
||
| return True, None | ||
|
|
||
| @classmethod | ||
| def preview_cron_runs(cls, expression: str, count: int = 3) -> list[datetime]: | ||
| """ | ||
| Return the next ``count`` run times for a cron expression. | ||
|
|
||
| Returns an empty list if the expression is invalid. Times are computed | ||
| in the cluster's timezone, matching how django-q2 will actually run it. | ||
| """ | ||
| is_valid, _ = cls.validate_cron_expression(expression) | ||
| if not is_valid: | ||
| return [] | ||
|
|
||
| from croniter import croniter | ||
|
|
||
| base = timezone.localtime(timezone.now()) | ||
| itr = croniter(expression.strip(), base) | ||
| return [itr.get_next(datetime) for _ in range(count)] | ||
|
|
||
| @classmethod | ||
| def pause_all_schedules(cls, user=None) -> int: | ||
| """ | ||
|
|
@@ -371,6 +468,7 @@ def resume_all_schedules(cls) -> int: | |
| ScriptSchedule.RunMode.DAILY, | ||
| ScriptSchedule.RunMode.WEEKLY, | ||
| ScriptSchedule.RunMode.MONTHLY, | ||
| ScriptSchedule.RunMode.CRON, | ||
| ], | ||
| ).select_related("script"): | ||
| ids = cls.sync_schedule(schedule) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Describe the supported cron syntax precisely.
The implementation accepts standard five-field expressions, not “any cron expression”;
@dailyshortcuts and six-field expressions are explicitly rejected indocs/scheduling.md.Proposed wording
📝 Committable suggestion
🤖 Prompt for AI Agents