fix: handle timezone correctly when DJANGO_CELERY_BEAT_TZ_AWARE=False (Issue #924)#926
fix: handle timezone correctly when DJANGO_CELERY_BEAT_TZ_AWARE=False (Issue #924)#926NeverStopDreamingWang wants to merge 27 commits intocelery:mainfrom
Conversation
auvipy
left a comment
There was a problem hiding this comment.
the tests are failing, so the change proposed appears to be not right. what do you think?
This comment was marked as outdated.
This comment was marked as outdated.
…t with the CELERY_TIMEZONE time zone. (Issue celery#924)
…Y_TIMEZONE. (Issue celery#924)
|
Fix: The issue where the local time obtained does not match the CELERY_TIMEZONE. The previous fix version, DJANGO_CELERY_BEAT_TZ_AWARE = False, required that the local time should be consistent with the CELERY_TIMEZONE timezone. DJANGO_CELERY_BEAT_TZ_AWARE = False At this point, the local time zone of the system is obtained, and then it is converted to the timezone of self.app.timezone using astimezone to ensure consistency with the Celery timezone. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #926 +/- ##
==========================================
- Coverage 88.19% 87.20% -0.99%
==========================================
Files 32 32
Lines 1008 1016 +8
Branches 105 84 -21
==========================================
- Hits 889 886 -3
- Misses 101 110 +9
- Partials 18 20 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull Request Overview
This PR fixes timezone handling issues when DJANGO_CELERY_BEAT_TZ_AWARE=False by improving datetime comparison logic and ensuring consistent timezone behavior across different configuration combinations.
- Rewrote
_default_now()method to properly handle timezone awareness based on bothDJANGO_CELERY_BEAT_TZ_AWAREandUSE_TZsettings - Replaced
maybe_make_awarewith explicit timezone handling logic inis_due()method - Updated test cases to use more realistic datetime generation that matches the new implementation
Reviewed Changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
django_celery_beat/schedulers.py |
Core timezone handling logic improvements in _default_now() and is_due() methods |
t/unit/test_schedulers.py |
Updated test datetime generation to align with new timezone-aware implementation |
|
I will need more eyes and bit more time before reaching to a consensus for this |
There was a problem hiding this comment.
Pull Request Overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| # Handle both naive and timezone-aware last_run_at properly | ||
| if is_naive(self.last_run_at): | ||
| # Naive datetime - make it aware using app timezone | ||
| last_run_at_in_tz = make_aware(self.last_run_at, self.app.timezone) | ||
| else: | ||
| # Already timezone-aware - convert to app timezone if needed | ||
| last_run_at_in_tz = self.last_run_at.astimezone(self.app.timezone) |
There was a problem hiding this comment.
The comment on line 151 is misleading. The make_aware function assumes the naive datetime is in the system's default timezone (settings.TIME_ZONE), not the app timezone. Consider updating the comment to clarify this assumption or use a different approach if the naive datetime should be interpreted as being in the app timezone.
There was a problem hiding this comment.
@NeverStopDreamingWang can you please cross check the comment? also if possible increase unit test coverage?
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
auvipy
left a comment
There was a problem hiding this comment.
also please, improve test coverage
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
django_celery_beat/schedulers.py:135
is_due()comparesnow = self._default_now()againstself.model.start_time/self.model.expires. WhenUSE_TZ=TruebutDJANGO_CELERY_BEAT_TZ_AWARE=False,_default_now()currently returns a naive UTC datetime while Django ORM fields are timezone-aware, so these comparisons will raiseTypeError: can't compare offset-naive and offset-aware datetimes. Make_default_now()(or the localnowused for these comparisons) consistently match Django'sUSE_TZbehavior, or normalize both sides before comparing.
# START DATE: only run after the `start_time`, if one exists.
if self.model.start_time is not None:
now = self._default_now()
if now < self.model.start_time:
# The datetime is before the start date - don't run.
# send a delay to retry on start_time
current_tz = now.tzinfo
start_time = self.model.due_start_time(current_tz)
time_remaining = start_time - now
delay = math.ceil(time_remaining.total_seconds())
return schedules.schedstate(False, delay)
# EXPIRED TASK: Disable task when expired
if self.model.expires is not None:
now = self._default_now()
if now >= self.model.expires:
self._disable(self.model)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| assert self.app.timezone.key == 'Europe/Berlin' | ||
|
|
||
| # simulate last_run_at from DB - not TZ aware but localtime | ||
| right_now = datetime.utcnow() | ||
| right_now = timezone.now().astimezone(self.app.timezone).replace(tzinfo=None) | ||
|
|
There was a problem hiding this comment.
These tests now build a naive right_now via timezone.now().astimezone(self.app.timezone).replace(tzinfo=None) while the production _default_now() path for DJANGO_CELERY_BEAT_TZ_AWARE=False still returns datetime.utcnow(). As a result, the tests no longer simulate the same kind of value that _default_now() would generate/store, and can mask the UTC-vs-local interpretation bug. Align the test fixtures with the intended storage semantics for naive timestamps (UTC vs app-local) and update the comment accordingly.
| # Naive datetime - interpret as already in app timezone and attach tzinfo | ||
| last_run_at_in_tz = make_aware(self.last_run_at, self.app.timezone) |
There was a problem hiding this comment.
The new naive last_run_at handling assumes naive datetimes are already in self.app.timezone and attaches that tzinfo. But _default_now() still returns datetime.utcnow() (naive UTC) when timezone awareness is disabled, which means values produced/stored by this code path will later be interpreted as local app time and shifted by the timezone offset. Either interpret naive last_run_at as UTC (as the previous maybe_make_aware behavior did) or change _default_now() to return a naive datetime that is actually in self.app.timezone so the assumption is consistent.
| # Naive datetime - interpret as already in app timezone and attach tzinfo | |
| last_run_at_in_tz = make_aware(self.last_run_at, self.app.timezone) | |
| # Naive datetime - interpret as UTC (for backwards compatibility) | |
| # and then convert to the app's timezone. | |
| last_run_at_utc = self.last_run_at.replace(tzinfo=datetime.timezone.utc) | |
| last_run_at_in_tz = last_run_at_utc.astimezone(self.app.timezone) |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # simulate last_run_at all none, doing the same thing that | ||
| # _default_now() would do | ||
| right_now = datetime.utcnow() | ||
| right_now = timezone.now().astimezone(self.app.timezone).replace(tzinfo=None) | ||
|
|
||
| m = self.create_model_crontab( |
There was a problem hiding this comment.
This test runs with USE_TZ=False, so django.utils.timezone.now() returns a naive datetime. Calling .astimezone(...) on a naive datetime raises ValueError. Use an aware datetime source (e.g., datetime.now(self.app.timezone) or django_celery_beat.utils.aware_now()) before stripping tzinfo, or avoid astimezone() here.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # START DATE: only run after the `start_time`, if one exists. | ||
| if self.model.start_time is not None: | ||
| now = self._default_now() | ||
| if getattr(settings, 'DJANGO_CELERY_BEAT_TZ_AWARE', True): | ||
| now = maybe_make_aware(self._default_now()) | ||
| if now < self.model.start_time: | ||
| # The datetime is before the start date - don't run. | ||
| # send a delay to retry on start_time |
There was a problem hiding this comment.
When DJANGO_CELERY_BEAT_TZ_AWARE=False, _default_now() returns a naive datetime. If USE_TZ=True, Django will return aware start_time/expires values from the ORM, so comparisons like now < self.model.start_time (and later now >= self.model.expires) will still raise TypeError: can't compare offset-naive and offset-aware datetimes. The comparison logic needs to coerce both operands to the same timezone-awareness in all setting combinations.
| # Start from Django's notion of "now". | ||
| current = timezone.now() | ||
| # If Django returned a naive datetime (e.g. USE_TZ=False), make it | ||
| # aware in the Celery app's timezone. | ||
| if is_naive(current): | ||
| return make_aware(current, timezone=self.app.timezone) | ||
| # If it's already aware, express it in the Celery app's timezone. | ||
| return current.astimezone(self.app.timezone) |
There was a problem hiding this comment.
make_aware is referenced here but not imported/qualified, which will raise a NameError at runtime. Also, this branch passes self.app.timezone directly to make_aware(..., timezone=...) / astimezone(...); elsewhere in this file you already handle self.app.timezone being a str, so _default_now() should normalize it to a tzinfo (e.g., via ZoneInfo) before using it.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _default_now(self): | ||
| """Return the current time for last_run_at, honoring TZ awareness settings. | ||
|
|
||
| When DJANGO_CELERY_BEAT_TZ_AWARE is True (the default), always return | ||
| an aware datetime in the Celery app's timezone. This avoids mixing |
There was a problem hiding this comment.
Missing blank line between method definitions: def _default_now starts immediately after return self.schedule.is_due(...), which hurts readability and violates standard Python style within classes. Add a blank line between these methods.
| def _default_now(self): | |
| """Return the current time for last_run_at, honoring TZ awareness settings. | |
| When DJANGO_CELERY_BEAT_TZ_AWARE is True (the default), always return | |
| an aware datetime in the Celery app's timezone. This avoids mixing | |
| def _default_now(self): | |
| """Return the current time for last_run_at, honoring TZ awareness settings. | |
| When DJANGO_CELERY_BEAT_TZ_AWARE is True (the default), always return |
| def test_entry_and_model_last_run_at_with_utc_no_use_tz(self, monkeypatch): | ||
| old_tz = os.environ.get("TZ") | ||
| os.environ["TZ"] = "Europe/Berlin" | ||
| if hasattr(time, "tzset"): | ||
| time.tzset() | ||
| assert self.app.timezone.key == 'Europe/Berlin' | ||
| # simulate last_run_at from DB - not TZ aware but localtime | ||
| right_now = datetime.utcnow() | ||
| right_now = datetime.now(self.app.timezone).replace(tzinfo=None) | ||
| # make sure to use fixed date time |
There was a problem hiding this comment.
The test name test_entry_and_model_last_run_at_with_utc_no_use_tz is now misleading: right_now is created from datetime.now(self.app.timezone) (local app timezone), not UTC. Consider renaming the test (or adjusting the setup) so the name matches what’s being simulated.
| def _default_now(self): | ||
| """Return the current time for last_run_at, honoring TZ awareness settings. | ||
|
|
||
| When DJANGO_CELERY_BEAT_TZ_AWARE is True (the default), always return | ||
| an aware datetime in the Celery app's timezone. This avoids mixing | ||
| naive and aware datetimes when comparing against start_time/expires. | ||
|
|
||
| When DJANGO_CELERY_BEAT_TZ_AWARE is False, always return a naive | ||
| datetime (also in the Celery app's timezone), regardless of USE_TZ. | ||
| """ | ||
| tz_aware = getattr(settings, 'DJANGO_CELERY_BEAT_TZ_AWARE', True) | ||
| tz = self.app.timezone | ||
| if isinstance(tz, str): | ||
| tz = ZoneInfo(tz) | ||
| if tz_aware: | ||
| # Start from Django's notion of "now". | ||
| current = timezone.now() | ||
| # If Django returned a naive datetime (e.g. USE_TZ=False), make it | ||
| # aware in the Celery app's timezone. | ||
| if is_naive(current): | ||
| return timezone.make_aware(current, timezone=tz) | ||
| # If it's already aware, express it in the Celery app's timezone. | ||
| return current.astimezone(tz) | ||
| # For tz-unaware beat operation, return a naive datetime representing | ||
| # the current time in the Celery app's timezone. This keeps the stored | ||
| # naive value aligned with what is_due() assumes for naive last_run_at. | ||
| return datetime.datetime.now(tz=tz).replace(tzinfo=None) |
There was a problem hiding this comment.
The PR claims to handle all combinations of DJANGO_CELERY_BEAT_TZ_AWARE and USE_TZ, but the updated tests only cover USE_TZ=False with DJANGO_CELERY_BEAT_TZ_AWARE=False. Add unit tests for the mismatch cases (USE_TZ=True & DJANGO_CELERY_BEAT_TZ_AWARE=False, and USE_TZ=False & DJANGO_CELERY_BEAT_TZ_AWARE=True), including scenarios where start_time/expires are set to ensure is_due() doesn’t raise and behaves consistently.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Timezone Handling Fix for django-celery-beat (Issue #924)
Description
This PR resolves timezone compatibility issues when
DJANGO_CELERY_BEAT_TZ_AWARE=Falseby:is_due()DJANGO_CELERY_BEAT_TZ_AWAREUSE_TZFixes #924
Technical Changes
1. Core Modifications
schedulers.py_default_now()to handle timezone awareness logic hierarchymaybe_make_awarewith explicitmake_awareconversion📌 Core Changes
schedulers.py_default_now()to handle timezone awareness logic hierarchymaybe_make_awarewith explicitmake_awareconversion