Skip to content

fix: handle timezone correctly when DJANGO_CELERY_BEAT_TZ_AWARE=False (Issue #924)#926

Open
NeverStopDreamingWang wants to merge 27 commits intocelery:mainfrom
NeverStopDreamingWang:main
Open

fix: handle timezone correctly when DJANGO_CELERY_BEAT_TZ_AWARE=False (Issue #924)#926
NeverStopDreamingWang wants to merge 27 commits intocelery:mainfrom
NeverStopDreamingWang:main

Conversation

@NeverStopDreamingWang
Copy link
Copy Markdown

@NeverStopDreamingWang NeverStopDreamingWang commented Jul 31, 2025

Timezone Handling Fix for django-celery-beat (Issue #924)

Description

This PR resolves timezone compatibility issues when DJANGO_CELERY_BEAT_TZ_AWARE=False by:

  1. Ensuring consistent datetime comparisons in is_due()
  2. Properly handling all combinations of:
    • DJANGO_CELERY_BEAT_TZ_AWARE
    • USE_TZ

Fixes #924

Technical Changes

1. Core Modifications

File Changes
schedulers.py - Rewrote _default_now() to handle timezone awareness logic hierarchy
- Replaced maybe_make_aware with explicit make_aware conversion

📌 Core Changes

File Changes
schedulers.py - Rewrote _default_now() to handle timezone awareness logic hierarchy
- Replaced maybe_make_aware with explicit make_aware conversion

This comment was marked as outdated.

Copy link
Copy Markdown
Member

@auvipy auvipy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the tests are failing, so the change proposed appears to be not right. what do you think?

@auvipy auvipy requested a review from Copilot July 31, 2025 15:03

This comment was marked as outdated.

@NeverStopDreamingWang

This comment was marked as outdated.

@NeverStopDreamingWang
Copy link
Copy Markdown
Author

NeverStopDreamingWang commented Aug 1, 2025

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
Copy link
Copy Markdown

codecov Bot commented Aug 1, 2025

Codecov Report

❌ Patch coverage is 60.00000% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.20%. Comparing base (f1f239c) to head (edbec2d).
⚠️ Report is 48 commits behind head on main.

Files with missing lines Patch % Lines
django_celery_beat/schedulers.py 60.00% 4 Missing and 4 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@auvipy auvipy requested a review from Copilot August 1, 2025 04:49

This comment was marked as outdated.

@auvipy auvipy requested a review from Copilot August 4, 2025 09:51
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 both DJANGO_CELERY_BEAT_TZ_AWARE and USE_TZ settings
  • Replaced maybe_make_aware with explicit timezone handling logic in is_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

Comment thread django_celery_beat/schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
@auvipy
Copy link
Copy Markdown
Member

auvipy commented Aug 5, 2025

I will need more eyes and bit more time before reaching to a consensus for this

@auvipy auvipy requested a review from Copilot October 4, 2025 07:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread django_celery_beat/schedulers.py Outdated
Comment on lines +149 to +155
# 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)
Copy link

Copilot AI Oct 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NeverStopDreamingWang can you please cross check the comment? also if possible increase unit test coverage?

Comment thread django_celery_beat/schedulers.py Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@auvipy auvipy requested a review from Copilot March 1, 2026 17:58
Copy link
Copy Markdown
Member

@auvipy auvipy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also please, improve test coverage

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() compares now = self._default_now() against self.model.start_time / self.model.expires. When USE_TZ=True but DJANGO_CELERY_BEAT_TZ_AWARE=False, _default_now() currently returns a naive UTC datetime while Django ORM fields are timezone-aware, so these comparisons will raise TypeError: can't compare offset-naive and offset-aware datetimes. Make _default_now() (or the local now used for these comparisons) consistently match Django's USE_TZ behavior, 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.

Comment thread django_celery_beat/schedulers.py Outdated
Comment thread t/unit/test_schedulers.py
Comment on lines 333 to 337
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)

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread django_celery_beat/schedulers.py Outdated
Comment thread t/unit/test_schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
Comment on lines +151 to +152
# 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)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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)

Copilot uses AI. Check for mistakes.
auvipy and others added 2 commits March 2, 2026 00:35
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread t/unit/test_schedulers.py
Comment on lines 452 to 456
# 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(
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please cross check this

Comment thread django_celery_beat/schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
Comment thread t/unit/test_schedulers.py Outdated
Comment thread t/unit/test_schedulers.py Outdated
auvipy and others added 4 commits March 2, 2026 00:56
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread django_celery_beat/schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
Comment thread t/unit/test_schedulers.py Outdated
Comment thread django_celery_beat/schedulers.py Outdated
auvipy and others added 6 commits March 26, 2026 12:12
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 118 to 123
# 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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread django_celery_beat/schedulers.py Outdated
Comment on lines +179 to +186
# 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)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread django_celery_beat/schedulers.py Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread django_celery_beat/schedulers.py Outdated
Comment on lines +167 to +171
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread t/unit/test_schedulers.py
Comment on lines 360 to 368
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +193
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)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DJANGO_CELERY_BEAT_TZ_AWARE = False Time zone usage issue

4 participants