Repository: celery/celery

Issue:
celery.schedules.crontab is_due logic can trigger periodic celery beat tasks at arbitrary times unrelated to the crontab parameters when last_run_at value is sufficiently old
There's an issue in the implementation of `celery.schedules.crontab` method that can cause `crontab.is_due` to claim a schedule is due at a time that is completely unrelated to the given crontab parameters. This appears to happen in cases where the `last_run_at` value is older than the most recent feasible time the schedule could have run. It looks like that this issue was introduced as part of complex logic added nearly 8 years ago to improve the accuracy of time remaining estimates for `crontab` periodic tasks: 4ed89ec49582b540149cf06047f091ebd20fb300

## Checklist
Issue first observed in a celery deployment running celery v3.1.19

From inspecting the `celery.schedules.crontab` code it appears likely that this issue is present in all celery versions as far back as v2.1.0 through to v4.1.1 .

Unit tests (please see below) confirm issue is still present in master (b599b96960be9dd42b3dee82a58bd1d711df0317 at time of writing).

## Steps to reproduce
Please apply this patch to celery master branch, remove `@skip.todo` from the first new test, run unit tests, observe the first of these added unit tests fails:

```
diff --git a/t/unit/app/test_schedules.py b/t/unit/app/test_schedules.py
index a7b3025..0340461 100644
--- a/t/unit/app/test_schedules.py
+++ b/t/unit/app/test_schedules.py
@@ -26,6 +26,18 @@ def patch_crontab_nowfun(cls, retval):
         cls.nowfun = prev_nowfun
 
 
+def is_time_feasible_wrt_crontab_schedule(t, z):
+    # z : celery.schedules.crontab instance
+    t = z.maybe_make_aware(t)
+    return (
+        t.month in z.month_of_year and
+        (t.isoweekday() % 7) in z.day_of_week and
+        t.day in z.day_of_month and
+        t.hour in z.hour and
+        t.minute in z.minute
+    )
+
+
 @skip.unless_module('ephem')
 class test_solar:
 
@@ -803,3 +815,59 @@ class test_crontab_is_due:
             due, remaining = self.yearly.is_due(datetime(2009, 3, 12, 7, 30))
             assert not due
             assert remaining == 4 * 24 * 60 * 60 - 3 * 60 * 60
+
+    @skip.todo('FIXME crontab logic is defective when last_run_at is older than the most recent feasible time wrt schedule')
+    def test_daily_execution_if_last_run_at_was_days_ago_and_current_time_does_not_match_crontab_schedule_then_execution_is_not_due(self):
+        z = self.crontab(hour=7, minute=30)
+        last_run_at = datetime(2018, 6, 1, 7, 30)
+        now = datetime(2018, 6, 9, 23, 48)
+        expected_next_execution_time = datetime(2018, 6, 10, 7, 30)
+        expected_remaining = (expected_next_execution_time - now).total_seconds()
+        # check our assumptions
+        assert is_time_feasible_wrt_crontab_schedule(last_run_at, z)
+        assert not is_time_feasible_wrt_crontab_schedule(now, z)
+        assert is_time_feasible_wrt_crontab_schedule(expected_next_execution_time, z)
+        assert now < expected_next_execution_time
+        assert expected_remaining == (7 * 60 + 30 + 12) * 60
+        # test is_due
+        with patch_crontab_nowfun(z, now):
+            due, remaining = z.is_due(last_run_at=last_run_at)
+            assert remaining == expected_remaining
+            assert not due
+
+    def test_daily_execution_if_last_run_at_was_the_most_recent_feasible_time_wrt_schedule_in_past_and_current_time_does_not_match_crontab_schedule_then_execution_is_not_due(self):
+        z = self.crontab(hour=7, minute=30)
+        last_run_at = datetime(2018, 6, 9, 7, 30)
+        now = datetime(2018, 6, 9, 23, 48)
+        expected_next_execution_time = datetime(2018, 6, 10, 7, 30)
+        expected_remaining = (expected_next_execution_time - now).total_seconds()
+        # check our assumptions
+        assert is_time_feasible_wrt_crontab_schedule(last_run_at, z)
+        assert not is_time_feasible_wrt_crontab_schedule(now, z)
+        assert is_time_feasible_wrt_crontab_schedule(expected_next_execution_time, z)
+        assert now < expected_next_execution_time
+        assert expected_remaining == (7 * 60 + 30 + 12) * 60
+        # test is_due
+        with patch_crontab_nowfun(z, now):
+            due, remaining = z.is_due(last_run_at=last_run_at)
+            assert remaining == expected_remaining
+            assert not due
+
+    def test_daily_execution_if_last_run_at_was_more_recent_than_the_most_recent_feasible_time_wrt_schedule_in_past_and_current_time_does_not_match_crontab_schedule_then_execution_is_not_due(self):
+        z = self.crontab(hour=7, minute=30)
+        last_run_at = datetime(2018, 6, 9, 10, 30) # not feasible wrt to current schedule. case can happen if schedule is modified after a run
+        now = datetime(2018, 6, 9, 23, 48)
+        expected_next_execution_time = datetime(2018, 6, 10, 7, 30)
+        expected_remaining = (expected_next_execution_time - now).total_seconds()
+        # check our assumptions
+        assert not is_time_feasible_wrt_crontab_schedule(last_run_at, z)
+        assert not is_time_feasible_wrt_crontab_schedule(now, z)
+        assert is_time_feasible_wrt_crontab_schedule(expected_next_execution_time, z)
+        assert now < expected_next_execution_time
+        assert expected_remaining == (7 * 60 + 30 + 12) * 60
+        # test is_due
+        with patch_crontab_nowfun(z, now):
+            due, remaining = z.is_due(last_run_at=last_run_at)
+            assert remaining == expected_remaining
+            assert not due
+
```

## Expected behavior
No matter what the value of `last_run_at` is, `crontab.is_due(last_run_at)` should never return `schedstate(True, rem)` when the current time `now` is not feasible with respect to the given crontab parameters.

## Actual behavior
if `last_run_at` value is older than the most recent time that is feasible with respect to the given crontab parameters then `crontab.is_due(last_run_at)` returns `schedstate(True, rem)`  when the current time is not feasible with respect to the crontab parameters.

## Comments
This behaviour is surprising as it is an undocumented departure from cron-like behaviour. This behaviour is somewhat like an undocumented variant of uncontrollable `anacron` behaviour. "Uncontrollable" in the sense that unlike `anacrontab`'s `START_HOURS_RANGE` parameter - there is no control at all over when tasks will be run when a scheduled execution is missed.

We experience this issue in production using the venerable Celery version of 3.1.19 : we have a celery beat process that is backed by a database using custom scheduler code that is derived from the django-celery-beat scheduler. Suppose we stop this celery beat process for some reason such as scheduled maintenance or during a deployment that needs to redeploy code to our celery cluster. When we later restart celery beat again then some or all `celery.schedules.crontab` scheduled tasks may immediately execute if there was a time during the celery beat downtime period that matches the crontab parameters.

One hack that can mitigate this behaviour is updating the "last_run_at" value for all celery crontab scheduled periodic tasks to the current time when celery beat starts, before celery beat makes any calls to the `celery.schedules.crontab.is_due` method.




Discussion:
I am in the process of rewriting the `celery.schedules.crontab` logic to fix this cleanly, if i can get the green light from my client to release a fix upstream i will submit a pull request with a proposed patch to fix this within the next week or two.


@fcostin did you ever complete your `crontab` rewrite and/or (not) get permission to release?
Can you send a PR with proposed fix?
@fdemmer -

One crude way to fix this is to simply revert the old change that introduced this issue: https://github.com/celery/celery/commit/4ed89ec49582b540149cf06047f091ebd20fb300 , in particular the parts of the patch to `celery/schedules.py` that introduce the much more complicated logic that estimates the time remaining until the next job.

This would have downside of breaking the logic that estimates when the next scheduled task is due, and causing the scheduler to need to poll every second to re-check if a task is due yet. But it would fix this issue.

I wrote a better patch for fixing this last year (that didn't rely on polling every 1 second, and could estimate remaining time until the next scheduled task) which resolved the issue internally but finished up my contract shortly after and ran out of time to lobby for permission to release the fix upstream. I'll reach out and see if anyone there is willing to work with me to get the patch released (chances of this are low, but worth a try...).

thanks for the response @fcostin :)

my requirement is adding tz support. i thought about rewriting/fixing/adding to celery's crontab, but decided it would be better to not hack around in that code or pile up on top, so i wrote my own using [tzcron](https://github.com/bloomberg/tzcron) to parse the cron expression and calculate the next event.

- it calculates the timestamp for the next trigger based only on "now" (ignoring `last_run_at`), so the bug from this issue _should_ be fixed.
- the cron parameters can be passed as string or separate args, in the order requested in #4570

https://gist.github.com/fdemmer/7551bff2bab80b56aac5018060aded55

it hasn't been used a lot and has no tests, but _seems_ to work and is licensed under MIT like all my gists.
hi @fdemmer -- adding timezone support sounds like a great idea, as does your implementation that avoids using the existing celery crontab scheduler logic completely.

Your `pytzcrontab` implementation looks pretty good . If i was aware of this last year that would have likely been a cleaner fix than my patch. thanks for sharing!

> it hasn't been used a lot and has no tests, but seems to work and is licensed under MIT like all my gists.

Sounds promising!

I have subjected your `pytzcrontab` class to the existing unit tests for the crontab scheduler -- with some patches to those unit tests to fix up timezones as necessary -- including adding three new tests from my patch in this issue.

here are my experimental patches, including adding `celery-tzcron.py` from your gist as `celery/scheduler_tzcron.py`:

https://github.com/fcostin/celery/commits/scheduler_tzcron_experiments

the results are fairly good:

1. `pytzcrontab` appears to fix this issue, as you predict. this makes sense.
2. with a small patch for compatibility with the existing `crontab` scheduler to define `hour` ... `day_of_year` properties, `pytzcrontab` passes most of the old unit tests
3. with some more patches to your `is_due` and `remaining_estimate` logic, the `pytzcrontab` implementation behaves in a closer way to how the existing `crontab` class decides when things are due. This change appears to be necessary so that `pytzcrontab` does not say the schedule is due when the schedule last ran a very short time ago (e.g. 0 seconds ago!). i am not completely happy with my proposed change here, it doesn't seem completely clean, maybe you can think of a nicer way to do it.
3. `pytzcrontab` introduces one regression that the existing celery unit tests can detect-- it goes into an infinite loop if we try to schedule something on an impossible day (31st of april) . I looked for an obvious way to avoid this but didn't find a good one so i've marked the test as skipped, but this would be good to fix. I guess it could be fixed by additional up-front validation logic, but there is still some risk that a gap between validation and the behaviour of the `tzcron` library might lead to an infinite loop.

```
   @pytest.mark.skip("broken - pytzcrontab goes into an infinite loop here")
    def test_invalid_specification(self):
        # *** WARNING ***
        # This test triggers an infinite loop in case of a regression
        with pytest.raises(RuntimeError):
            self.next_ocurrance(
                self.pytzcrontab(day_of_month=31, month_of_year=4),
                datetime_utc(2010, 1, 28, 14, 30, 15),
            )
```

What do you think?

Would it make sense to contribute something like this as a pull request into celery itself?
This issue is fairly old is there a fix or a work around for this? I see the gist above but I'd rather not have to maintain some other version of celery.

Right now I'm facing an issue with duplicate ETL imports because of this. I run all crontab schedules but if I make an on the fly change, either adding a new periodic task or changing the schedule of one, it will trigger ALL of my tasks to run.
That's very unfortunate. We're sorry that this is happening.
I'm unaware of a workaround for this issue.

Unfortunately, we never received a PR which fixes this issue.
If you'd like to help, please take the patch above and do the work necessary to get it merged.
In all honesty this seems like its been an issue for a very long time and frankly surprised its not a much higher priority bug to be fixed. For a scheduling app to incorrectly schedule a task seems like a pretty big issue.

Anyways, I appreciate you taking the time to respond. I can try to look at the code, but in all honesty I'm not very good with following large projects like Celery. If I get some time over Christmas holiday I will have a go at trying to see where it can be fixed.

Stay safe and have a wonderful holiday!
> In all honesty this seems like its been an issue for a very long time and frankly surprised its not a much higher priority bug to be fixed. For a scheduling app to incorrectly schedule a task seems like a pretty big issue.
> 
> Anyways, I appreciate you taking the time to respond. I can try to look at the code, but in all honesty I'm not very good with following large projects like Celery. If I get some time over Christmas holiday I will have a go at trying to see where it can be fixed.
> 
> Stay safe and have a wonderful holiday!

if you can take the lead we could help you guide you through all the way
So now I'm not sure if my issue is related to this. I was trying to reproduce this with my app and wasn't able to reproduce it until today.

The way it triggered wasn't by adding/removing/updating a periodic task, but by adding a new schedule and then assigning that to a newly created periodic task. I don't know if this is due to the library I'm using to store it in a database or relating to this issue. I'll have to dig a bit deeper when I can.
We ran into this issue on our production setup and charged our subscription customers ahead of schedule when we restarted the services with a slight change in the scheduled time. This should be a higher priority bug.

I will try and post the steps to reproduce this reliably.
As a work around: since I'm using an external scheduler library for managing the beat schedules I just simply have an event listener on beat startup null out the last_run_at field for each schedule in the database. This will prevent beat from running any of the schedules prematurely.
> 
> 
> In all honesty this seems like its been an issue for a very long time and frankly surprised its not a much higher priority bug to be fixed. For a scheduling app to incorrectly schedule a task seems like a pretty big issue.

This is not a priority as none of us needs to fix this and no one is paying us to do so.

I am not sure if we are experiencing this same issue, here is what the celerybeat-schedule looks like:

```
>>> schedule = shelve.open("celerybeat-schedule", writeback=True)
>>> task = schedule["entries"]["My task"]
>>> task.last_run_at
datetime.datetime(2021, 5, 18, 8, 36, 0, 99, tzinfo=<UTC>)
>>> task.is_due()
schedstate(is_due=True, next=48.84625)
>>> task.schedule
<crontab: * 8 * * * (m/h/d/dM/MY)>
>>> datetime.now()
datetime.datetime(2021, 5, 18, 8, 39, 36, 601785)
```
The above task runs continously between 8am and 9 am __sometimes__ but not always... Using `celery==5.0.5`
@latusaki Do you feel like investigating further?
Our capacity is already maxed out with the 5.1 release.
@binu-mobiux Do you have a way to reproduce this issue?
@thedrow I can't commit as I don't have enough time, not expecting a fix, was just unsure if this bug is the same described above. I have now changed my crontab from 8am to 7:59 and waiting to see if the problem resurfaces. 

**Update**
With time set to 7:59 the task only runs once at the specified time, haven't observed the issue over the last two weeks.
I have the same issue. I use crontab(hour='*/3') and once the top of the hour hits, it runs on an infinite loop.
@dangell7 Do you have time to investigate or contribute a PR?

Resolving patch:
diff --git a/celery/app/defaults.py b/celery/app/defaults.py
--- a/celery/app/defaults.py
+++ b/celery/app/defaults.py
@@ -78,6 +78,7 @@ def __repr__(self):
         scheduler=Option('celery.beat:PersistentScheduler'),
         schedule_filename=Option('celerybeat-schedule'),
         sync_every=Option(0, type='int'),
+        cron_starting_deadline=Option(None, type=int)
     ),
     broker=Namespace(
         url=Option(None, type='string'),
diff --git a/celery/schedules.py b/celery/schedules.py
--- a/celery/schedules.py
+++ b/celery/schedules.py
@@ -36,7 +36,6 @@
 {0._orig_day_of_week} (m/h/dM/MY/d)>\
 """
 
-
 SOLAR_INVALID_LATITUDE = """\
 Argument latitude {lat} is invalid, must be between -90 and 90.\
 """
@@ -608,16 +607,48 @@ def remaining_estimate(self, last_run_at, ffwd=ffwd):
     def is_due(self, last_run_at):
         """Return tuple of ``(is_due, next_time_to_run)``.
 
+        If :setting:`beat_cron_starting_deadline`  has been specified, the
+        scheduler will make sure that the `last_run_at` time is within the
+        deadline. This prevents tasks that could have been run according to
+        the crontab, but didn't, from running again unexpectedly.
+
         Note:
             Next time to run is in seconds.
 
         SeeAlso:
             :meth:`celery.schedules.schedule.is_due` for more information.
         """
+
         rem_delta = self.remaining_estimate(last_run_at)
-        rem = max(rem_delta.total_seconds(), 0)
+        rem_secs = rem_delta.total_seconds()
+        rem = max(rem_secs, 0)
         due = rem == 0
-        if due:
+
+        deadline_secs = self.app.conf.beat_cron_starting_deadline
+        has_passed_deadline = False
+        if deadline_secs is not None:
+            # Make sure we're looking at the latest possible feasible run
+            # date when checking the deadline.
+            last_date_checked = last_run_at
+            last_feasible_rem_secs = rem_secs
+            while rem_secs < 0:
+                last_date_checked = last_date_checked + abs(rem_delta)
+                rem_delta = self.remaining_estimate(last_date_checked)
+                rem_secs = rem_delta.total_seconds()
+                if rem_secs < 0:
+                    last_feasible_rem_secs = rem_secs
+
+            # if rem_secs becomes 0 or positive, second-to-last
+            # last_date_checked must be the last feasible run date.
+            # Check if the last feasible date is within the deadline
+            # for running
+            has_passed_deadline = -last_feasible_rem_secs > deadline_secs
+            if has_passed_deadline:
+                # Should not be due if we've passed the deadline for looking
+                # at past runs
+                due = False
+
+        if due or has_passed_deadline:
             rem_delta = self.remaining_estimate(self.now())
             rem = max(rem_delta.total_seconds(), 0)
         return schedstate(due, rem)


Test patch:
diff --git a/t/unit/app/test_beat.py b/t/unit/app/test_beat.py
--- a/t/unit/app/test_beat.py
+++ b/t/unit/app/test_beat.py
@@ -696,16 +696,19 @@ def now_func():
                 'first_missed', 'first_missed',
                 last_run_at=now_func() - timedelta(minutes=2),
                 total_run_count=10,
+                app=self.app,
                 schedule=app_schedule['first_missed']['schedule']),
             'second_missed': beat.ScheduleEntry(
                 'second_missed', 'second_missed',
                 last_run_at=now_func() - timedelta(minutes=2),
                 total_run_count=10,
+                app=self.app,
                 schedule=app_schedule['second_missed']['schedule']),
             'non_missed': beat.ScheduleEntry(
                 'non_missed', 'non_missed',
                 last_run_at=now_func() - timedelta(minutes=2),
                 total_run_count=10,
+                app=self.app,
                 schedule=app_schedule['non_missed']['schedule']),
         }
 
diff --git a/t/unit/app/test_schedules.py b/t/unit/app/test_schedules.py
--- a/t/unit/app/test_schedules.py
+++ b/t/unit/app/test_schedules.py
@@ -800,3 +800,136 @@ def test_yearly_execution_is_not_due(self):
             due, remaining = self.yearly.is_due(datetime(2009, 3, 12, 7, 30))
             assert not due
             assert remaining == 4 * 24 * 60 * 60 - 3 * 60 * 60
+
+    def test_execution_not_due_if_task_not_run_at_last_feasible_time_outside_deadline(
+            self):
+        """If the crontab schedule was added after the task was due, don't
+        immediately fire the task again"""
+        # could have feasibly been run on 12/5 at 7:30, but wasn't.
+        self.app.conf.beat_cron_starting_deadline = 3600
+        last_run = datetime(2022, 12, 4, 10, 30)
+        now = datetime(2022, 12, 5, 10, 30)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # Run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert not due
+
+    def test_execution_not_due_if_task_not_run_at_last_feasible_time_no_deadline_set(
+            self):
+        """Same as above test except there's no deadline set, so it should be
+         due"""
+        last_run = datetime(2022, 12, 4, 10, 30)
+        now = datetime(2022, 12, 5, 10, 30)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # Run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert due
+
+    def test_execution_due_if_task_not_run_at_last_feasible_time_within_deadline(
+            self):
+        # Could have feasibly been run on 12/5 at 7:30, but wasn't. We are
+        # still within a 1 hour deadline from the
+        # last feasible run, so the task should still be due.
+        self.app.conf.beat_cron_starting_deadline = 3600
+        last_run = datetime(2022, 12, 4, 10, 30)
+        now = datetime(2022, 12, 5, 8, 0)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert due
+
+    def test_execution_due_if_task_not_run_at_any_feasible_time_within_deadline(
+            self):
+        # Could have feasibly been run on 12/4 at 7:30, or 12/5 at 7:30,
+        # but wasn't. We are still within a 1 hour
+        # deadline from the last feasible run (12/5), so the task should
+        # still be due.
+        self.app.conf.beat_cron_starting_deadline = 3600
+        last_run = datetime(2022, 12, 3, 10, 30)
+        now = datetime(2022, 12, 5, 8, 0)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # Run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert due
+
+    def test_execution_not_due_if_task_not_run_at_any_feasible_time_outside_deadline(
+            self):
+        """Verifies that remaining is still the time to the next
+        feasible run date even though the original feasible date
+        was passed over in favor of a newer one."""
+        # Could have feasibly been run on 12/4 or 12/5 at 7:30,
+        # but wasn't.
+        self.app.conf.beat_cron_starting_deadline = 3600
+        last_run = datetime(2022, 12, 3, 10, 30)
+        now = datetime(2022, 12, 5, 11, 0)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert not due
+
+    def test_execution_not_due_if_last_run_in_future(self):
+        # Should not run if the last_run hasn't happened yet.
+        last_run = datetime(2022, 12, 6, 7, 30)
+        now = datetime(2022, 12, 5, 10, 30)
+        expected_next_execution_time = datetime(2022, 12, 7, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # Run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert not due
+            assert remaining == expected_remaining
+
+    def test_execution_not_due_if_last_run_at_last_feasible_time(self):
+        # Last feasible time is 12/5 at 7:30
+        last_run = datetime(2022, 12, 5, 7, 30)
+        now = datetime(2022, 12, 5, 10, 30)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # Run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert not due
+
+    def test_execution_not_due_if_last_run_past_last_feasible_time(self):
+        # Last feasible time is 12/5 at 7:30
+        last_run = datetime(2022, 12, 5, 8, 30)
+        now = datetime(2022, 12, 5, 10, 30)
+        expected_next_execution_time = datetime(2022, 12, 6, 7, 30)
+        expected_remaining = (
+                    expected_next_execution_time - now).total_seconds()
+
+        # Run the daily (7:30) crontab with the current date
+        with patch_crontab_nowfun(self.daily, now):
+            due, remaining = self.daily.is_due(last_run)
+            assert remaining == expected_remaining
+            assert not due