Repository: frappe/frappe

Issue:
Cron server script runs twice
<!--
Welcome to the Frappe Framework issue tracker! Before creating an issue, please heed the following:

1. This tracker should only be used to report bugs and request features / enhancements to Frappe
    - For questions and general support, use https://stackoverflow.com/questions/tagged/frappe
    - For documentation issues, refer to https://frappeframework.com/docs/user/en or the developer cheetsheet https://github.com/frappe/frappe/wiki/Developer-Cheatsheet
2. Use the search function before creating a new issue. Duplicates will be closed and directed to
   the original discussion.
3. When making a bug report, make sure you provide all required information. The easier it is for
   maintainers to reproduce, the faster it'll be fixed.
4. If you think you know what the reason for the bug is, share it with us. Maybe put in a PR 😉
-->

## Description of the issue
Created a cron server script with "13 11 17 10 tue" cron format.
So it should run at 11:13 17/10/2023.
But it runs once after save and runs again at correct time.

## Context information (for bug reports)
![image](https://github.com/frappe/frappe/assets/710051/1531ef89-3fc0-4b0a-a7af-2fe77d626e11)

![image](https://github.com/frappe/frappe/assets/710051/21fd224d-e841-4598-967f-b79c9895009b)


**Output of `bench version`**
```
Bench 5.17.2
Frappe 14.52.0
```

## Steps to reproduce the issue

1.Create a server script as "Scheduler Event", "Event Frequency" as "Cron"
2.Make cron format as ```59 23 * * *```
3.Set script as
```frappe.log_error("Cron Test", "Test")```

### Observed result
Watch the Error Log. 
It creates a "Cron Test" log once and at the midnight.

### Expected result
It should only create the error log at the midnight.
### Stacktrace / full error message

```
None
```

## Additional information

OS version / distribution, `Frappe` install method, etc.
Manual install



Discussion:

Resolving patch:
diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
--- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
@@ -105,9 +105,12 @@ def get_next_execution(self):
 		if not self.cron_format:
 			self.cron_format = CRON_MAP[self.frequency]
 
-		return croniter(
-			self.cron_format, get_datetime(self.last_execution or datetime(2000, 1, 1))
-		).get_next(datetime)
+		# If this is a cold start then last_execution will not be set.
+		# Creation is set as fallback because if very old fallback is set job might trigger
+		# immediately, even when it's meant to be daily.
+		# A dynamic fallback like current time might miss the scheduler interval and job will never start.
+		last_execution = get_datetime(self.last_execution or self.creation)
+		return croniter(self.cron_format, last_execution).get_next(datetime)
 
 	def execute(self):
 		self.scheduler_log = None


Test patch:
diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py
--- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py
@@ -1,9 +1,12 @@
 # Copyright (c) 2019, Frappe Technologies and Contributors
 # License: MIT. See LICENSE
+from datetime import timedelta
+
 import frappe
 from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
 from frappe.tests.utils import FrappeTestCase
 from frappe.utils import get_datetime
+from frappe.utils.data import add_to_date, now_datetime
 
 
 class TestScheduledJobType(FrappeTestCase):
@@ -65,9 +68,34 @@ def test_monthly_job(self):
 		self.assertFalse(job.is_event_due(get_datetime("2019-01-31 23:59:59")))
 
 	def test_cron_job(self):
+		# Daily but offset by 45 minutes
+		job = frappe.get_doc(
+			"Scheduled Job Type",
+			dict(method="frappe.core.doctype.log_settings.log_settings.run_log_clean_up"),
+		)
+		self.assertEqual(
+			job.next_execution,
+			add_to_date(None, days=1).replace(hour=0, minute=45, second=0, microsecond=0),
+		)
 		# runs every 15 mins
 		job = frappe.get_doc("Scheduled Job Type", dict(method="frappe.oauth.delete_oauth2_data"))
 		job.db_set("last_execution", "2019-01-01 00:00:00")
+		self.assertEqual(job.next_execution, get_datetime("2019-01-01 00:15:00"))
 		self.assertTrue(job.is_event_due(get_datetime("2019-01-01 00:15:01")))
 		self.assertFalse(job.is_event_due(get_datetime("2019-01-01 00:05:06")))
 		self.assertFalse(job.is_event_due(get_datetime("2019-01-01 00:14:59")))
+
+	def test_cold_start(self):
+		now = now_datetime()
+		just_before_12_am = now.replace(hour=11, minute=59, second=30)
+		just_after_12_am = now.replace(hour=0, minute=0, second=30) + timedelta(days=1)
+
+		job = frappe.new_doc("Scheduled Job Type")
+		job.frequency = "Daily"
+		job.set_user_and_timestamp()
+
+		with self.freeze_time(just_before_12_am):
+			self.assertFalse(job.is_event_due())
+
+		with self.freeze_time(just_after_12_am):
+			self.assertTrue(job.is_event_due())
diff --git a/frappe/tests/test_test_utils.py b/frappe/tests/test_test_utils.py
--- a/frappe/tests/test_test_utils.py
+++ b/frappe/tests/test_test_utils.py
@@ -1,5 +1,8 @@
+from datetime import timedelta
+
 import frappe
 from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils.data import now_datetime
 
 
 class TestTestUtils(FrappeTestCase):
@@ -27,6 +30,13 @@ def test_temp_setting_changes(self):
 		restored_settings = frappe.get_system_settings("logout_on_password_reset")
 		self.assertEqual(current_setting, restored_settings)
 
+	def test_time_freezing(self):
+		now = now_datetime()
+
+		tomorrow = now + timedelta(days=1)
+		with self.freeze_time(tomorrow):
+			self.assertEqual(now_datetime(), tomorrow)
+
 
 def tearDownModule():
 	"""assertions for ensuring tests didn't leave state behind"""
diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py
--- a/frappe/tests/utils.py
+++ b/frappe/tests/utils.py
@@ -7,9 +7,12 @@
 from contextlib import contextmanager
 from unittest.mock import patch
 
+import pytz
+
 import frappe
 from frappe.model.base_document import BaseDocument, get_controller
 from frappe.utils import cint
+from frappe.utils.data import convert_utc_to_timezone, get_datetime, get_system_timezone
 
 datetime_like_types = (datetime.datetime, datetime.date, datetime.time, datetime.timedelta)
 
@@ -154,6 +157,17 @@ def switch_site(self, site: str):
 		frappe.init(old_site, force=True)
 		frappe.connect()
 
+	@contextmanager
+	def freeze_time(self, time_to_freeze, *args, **kwargs):
+		from freezegun import freeze_time
+
+		# Freeze time expects UTC or tzaware objects. We have neither, so convert to UTC.
+		timezone = pytz.timezone(get_system_timezone())
+		fake_time_with_tz = timezone.localize(get_datetime(time_to_freeze)).astimezone(pytz.utc)
+
+		with freeze_time(fake_time_with_tz, *args, **kwargs):
+			yield
+
 
 class MockedRequestTestCase(FrappeTestCase):
 	def setUp(self):