# Celery Beat Not Running Tasks: A Troubleshooting Guide

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/celery-beat-not-running

> Why Celery beat stops sending periodic tasks or runs them twice: the beat process, queues, task names, schedules, time zones, and django-celery-beat.

**A Celery periodic task runs only when three things hold: a separate `celery beat` process is running, a worker consumes the queue the task is routed to, and that worker has the task registered under the name beat sends.** When one is missing, nothing fails and nothing runs. Start beat with `--loglevel=info` and look for `Scheduler: Sending due task`: if the line is missing, the problem is beat or the schedule it loaded; if it's there, the problem is between the broker and a worker. A task that runs twice almost always has two beats sending it, or a broker delivering it again.

_Figure: A periodic task runs only if all three hold: a beat process sends it, a worker reads the queue it is routed to, and that worker has the task registered under the name beat sends. Each marked point is where one of them breaks._

The causes below are in the order worth checking, each with a way to confirm it and a fix, checked against the source of Celery 5.6.3, django-celery-beat 2.9.0, and celery-redbeat 2.4.2, the current stable releases on October 25, 2026. For Flower, Prometheus, and worker health, see [Celery monitoring](https://cronradar.com/blog/celery-monitoring).

## Find Where the Run Stops

Run the first command only where beat isn't already running, or stop the beat service first: a second beat sends every due task again.

```bash
# 1. What did beat load, and does it send? Look for "Sending due task".
celery -A proj beat --loglevel=debug

# 2. Which workers are online, and which queues does each consume?
celery -A proj status
celery -A proj inspect active_queues

# 3. Does each worker know the task by the name beat sends?
celery -A proj inspect registered

# 4. Are messages piling up? (Redis broker, database 0, default queue "celery")
redis-cli -n 0 llen celery
```

A healthy beat logs this, abridged; the messages come from [beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L280):

```text
[2026-10-25 05:59:58,409: DEBUG/MainProcess] Current schedule:
<ScheduleEntry: send-invoices billing.tasks.send_invoices() <crontab: 0 6 * * * (m/h/dM/MY/d)>
[2026-10-25 06:00:00,004: INFO/MainProcess] Scheduler: Sending due task send-invoices (billing.tasks.send_invoices)
[2026-10-25 06:00:00,011: DEBUG/MainProcess] billing.tasks.send_invoices sent. id->1f0e8c62-5c1e-4c47-9a3b-2d1f6e0b7a19
[2026-10-25 06:00:00,012: DEBUG/MainProcess] beat: Waking up in 5.00 minutes.
```

| What you see                                  | Most likely cause                                   |
| --------------------------------------------- | --------------------------------------------------- |
| No beat process, or a banner and nothing else | Beat isn't running, or logs at WARNING              |
| `Sending due task`, but the task never starts | No worker reads its queue, or none knows the task   |
| `Received unregistered task` in a worker log  | The task module isn't imported, or the name differs |
| `Current schedule:` lacks the task            | Wrong setting name, or the task is disabled         |
| Runs at the wrong hour or day                 | `timezone`, or `crontab()` argument order           |
| Runs twice                                    | Two beats, a killed beat, or broker redelivery      |

## Beat Isn't Running, or Is Running Quietly

Beat is its own process, started with `celery -A proj beat` ([starting the scheduler](https://docs.celeryq.dev/en/v5.6.3/userguide/periodic-tasks.html#starting-the-scheduler)). A deployment that defines only `celery -A proj worker` has workers and no schedule, and so does one where beat crashed at startup and nothing restarted it.

`celery -A proj worker -B` embeds beat in that worker. Celery's docs call it convenient "if you'll never run more than one worker node" and say it "isn't recommended for production use." Every worker started with `-B` runs its own beat, so a second worker sends every task twice, and `-B` refuses to start on Windows ([worker.py](https://github.com/celery/celery/blob/v5.6.3/celery/bin/worker.py#L28-L30)).

A running beat can also look idle. Its `--loglevel` defaults to WARNING ([bin/beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/bin/beat.py#L38-L44)) and `Sending due task` is an INFO message, so a healthy beat prints its banner and nothing more; run it with `--loglevel=info`. At debug level, `beat: Waking up in 5.00 minutes.` is normal: the default scheduler sleeps at most 300 seconds and wakes sooner when an entry is due ([beat_max_loop_interval](https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#beat-max-loop-interval), [beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L362-L364)).

Fix: run exactly one beat as its own service under something that restarts it, such as a systemd unit, a Compose service with a restart policy, or a one-replica Kubernetes Deployment with the rollout setting shown under running twice.

## Beat Sends the Task, but No Worker Reads Its Queue

Beat publishes and moves on. It routes each task from `task_routes` or the entry's `"options": {"queue": ...}`, and with `task_create_missing_queues` (on by default) a routed queue is created on first use ([automatic routing](https://docs.celeryq.dev/en/v5.6.3/userguide/routing.html#automatic-routing)). A worker, though, consumes only the queues in `task_queues`, or the default queue `celery` when that's unset, unless `-Q` names others ([worker queues](https://docs.celeryq.dev/en/v5.6.3/userguide/workers.html#queues)). Route a task to `reports`, start workers without `-Q reports,celery`, and it waits in a queue nobody reads.

Confirm by comparing the task's queue with what `inspect active_queues` lists, then check the queue's length with `redis-cli llen reports` or `rabbitmqctl list_queues name messages consumers` ([inspecting queues](https://docs.celeryq.dev/en/v5.6.3/userguide/monitoring.html#monitoring-redis-queues)). On SQS, `inspect` doesn't work, because "SQS doesn't yet support worker remote control commands" ([SQS caveats](https://docs.celeryq.dev/en/v5.6.3/getting-started/backends-and-brokers/sqs.html#caveats)); check the message count in AWS. Fix: add the queue to a worker's `-Q`, or remove the route. The waiting messages all run once a consumer appears, unless the entry sets `expires`.

## The Worker Doesn't Know the Task

Beat sends a task by name and doesn't need to import it; the worker does. When a worker receives a name it hasn't registered, it logs this ([consumer.py](https://github.com/celery/celery/blob/v5.6.3/celery/worker/consumer/consumer.py#L71-L90)), rejects the message, and marks the task failed with `NotRegistered` ([consumer.py](https://github.com/celery/celery/blob/v5.6.3/celery/worker/consumer/consumer.py#L596-L627)), so nothing piles up:

```text
Received unregistered task of type 'billing.tasks.send_invoices'.
The message has been ignored and discarded.

Did you remember to import the module containing this task?
Or maybe you're using relative imports?
```

Two things usually cause it:

- **The module is never imported in the worker.** In Django, `app.autodiscover_tasks()` imports each installed app's `tasks.py`, and `proj/__init__.py` must import the Celery app so `@shared_task` binds to it ([Django first steps](https://docs.celeryq.dev/en/v5.6.3/django/first-steps-with-django.html)). Elsewhere, list task modules in `include` or `imports`.
- **The name differs.** A task's default name is its module plus the function name ([task names](https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#names)), so moving the function, or importing the module under a different path, changes it. The entry's `task` string must match exactly.

Confirm with `inspect registered`. Fix the import, or set `name=` on the task decorator and use the same string in the schedule.

## The Schedule Beat Loaded Isn't the One You Wrote

The `Current schedule:` block shows what beat loaded. Without starting beat, `python -c "from proj.celery import app; print(sorted(app.conf.beat_schedule))"` prints the entry names.

### The Setting Has the Wrong Name

With `app.config_from_object("django.conf:settings", namespace="CELERY")`, Celery reads uppercase names with a `CELERY_` prefix: `CELERY_BEAT_SCHEDULE`, `CELERY_TIMEZONE` ([Django first steps](https://docs.celeryq.dev/en/v5.6.3/django/first-steps-with-django.html)). Without a namespace, it's lowercase `beat_schedule`, or the pre-4.0 `CELERYBEAT_SCHEDULE` ([defaults.py](https://github.com/celery/celery/blob/v5.6.3/celery/app/defaults.py#L24-L80)). Any other spelling, such as `BEAT_SCHEDULE` in a namespaced Django project, is ignored without an error, and beat starts with an empty schedule.

### Crontab Arguments Aren't in Cron Order

Celery's signature is `crontab(minute, hour, day_of_week, day_of_month, month_of_year)` ([schedules.py](https://github.com/celery/celery/blob/v5.6.3/celery/schedules.py#L401-L413)), so `crontab(0, 6, 1)` is 06:00 every Monday, not 06:00 on the 1st. Pass keywords, or use `crontab.from_string("0 6 1 * *")`, which reads standard cron order (Celery 5.5 and later). The debug listing prints crontabs in standard order, so `crontab(0, 6, 1)` shows as `<crontab: 0 6 * * 1 (m/h/dM/MY/d)>`. Two more differences from Linux cron:

- **Sunday is 0 only.** Day of week takes 0 to 6, and 7 raises `ValueError` when the schedule is built ([schedules.py](https://github.com/celery/celery/blob/v5.6.3/celery/schedules.py#L473-L477)); cronie's [crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html) accepts 0 or 7.
- **Both day fields must match.** When `day_of_week` and `day_of_month` are both restricted, a date must satisfy both ([schedules.py](https://github.com/celery/celery/blob/v5.6.3/celery/schedules.py#L391-L398)), where Linux cron runs on either. `crontab(minute=0, hour=9, day_of_week=1, day_of_month="1-7")` is the first Monday of the month.

## The Task Runs at the Wrong Hour: Time Zones

A crontab follows the wall clock of the `timezone` setting ([timezone](https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#timezone)). When it's unset, Celery uses UTC, or the host's local zone if `enable_utc` is False ([base.py](https://github.com/celery/celery/blob/v5.6.3/celery/app/base.py#L1531-L1544)); in Django, an unset `CELERY_TIMEZONE` falls back to Django's `TIME_ZONE` ([utils.py](https://github.com/celery/celery/blob/v5.6.3/celery/app/utils.py#L129-L131)). So `crontab(minute=0, hour=6)` in a project with `TIME_ZONE = "Europe/Berlin"` runs at 04:00 or 05:00 UTC, depending on daylight saving time.

When `timezone` or `enable_utc` changes from the value recorded in the schedule file, the default scheduler clears its stored run times, logging `Reset: Timezone changed from 'UTC' to 'Europe/Berlin'` ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L545-L556)). Under a zone with daylight saving, a task's UTC time moves twice a year, and Celery's docs don't say what a crontab does with a local time that's skipped or repeated. Keep `timezone = "UTC"` unless the rule really is local; [cron timezone](https://cronradar.com/blog/cron-timezone) covers the trade-off.

## The Schedule File Is Unwritable, Not Kept, or Reset

The default `PersistentScheduler` stores each entry's last run time in `celerybeat-schedule`, in beat's working directory, possibly with a suffix such as `.db` from the dbm backend ([beat_schedule_filename](https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#beat-schedule-filename)). Move it with `celery -A proj beat -s /var/lib/celery/celerybeat-schedule`.

- **Beat can't write it.** On a read-only container filesystem, beat logs `Removing corrupted schedule file` with the underlying error, though nothing is corrupt, tries once more, and exits with `beat raised exception` ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L522-L541), [apps/beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/apps/beat.py#L111-L118)). Point `-s` at a writable volume.
- **It isn't kept.** A new entry's last run is the moment beat starts ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L127)), and an interval schedule first fires one full interval later ([entries](https://docs.celeryq.dev/en/v5.6.3/userguide/periodic-tasks.html#entries)). So a `timedelta(days=1)` entry in a container without a volume, redeployed more than once a day, never runs. A crontab entry runs at its next matching time; use crontab, or keep the file on a volume.
- **It's reset.** Corruption or a `timezone` change gives every entry a fresh start; [missed runs after downtime](https://cronradar.com/blog/missed-runs-after-downtime) covers what that does to catch-up.

## django-celery-beat: Wrong Scheduler, Disabled Tasks, Unseen Edits

Tasks created in the Django admin run only when beat uses the database scheduler: `celery -A proj beat -S django`, or `CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"`. If beat's banner shows `. scheduler -> celery.beat.PersistentScheduler`, the admin's tasks are ignored.

With the right scheduler, django-celery-beat 2.9.0 still skips a task ([schedulers.py](https://github.com/celery/django-celery-beat/blob/v2.9.0/django_celery_beat/schedulers.py#L112-L154)) when:

- `enabled` is off, `start_time` is in the future, or `one_off` is set and it has run once (beat then disables it).
- `expires` has passed. Beat disables the task, so moving `expires` later doesn't revive it; re-enable it too.
- Its crontab uses 7 for Sunday. The admin's help text says "Sunday is 0 or 7" ([models.py](https://github.com/celery/django-celery-beat/blob/v2.9.0/django_celery_beat/models.py#L293-L300)) and its python-crontab validator maps a lone 7 to Sunday ([crontab.py](https://gitlab.com/doctormo/python-crontab/-/blob/v3.4.0/crontab.py#L1347-L1352)), but Celery rejects it with `Invalid end range: 7 > 6.` ([schedules.py](https://github.com/celery/celery/blob/v5.6.3/celery/schedules.py#L312-L315)), and the database scheduler drops any task whose schedule raises `ValueError`, without a log line ([schedulers.py](https://github.com/celery/django-celery-beat/blob/v2.9.0/django_celery_beat/schedulers.py#L261-L269)). Use 0.

Each crontab row has its own `timezone`, defaulting to `CELERY_TIMEZONE` or else UTC, not Django's `TIME_ZONE` ([models.py](https://github.com/celery/django-celery-beat/blob/v2.9.0/django_celery_beat/models.py#L64-L76)). So in a project that sets only `TIME_ZONE`, an admin-made crontab runs on UTC unless someone picked its zone.

Beat reloads after model `save()` or `delete()`, logging `DatabaseScheduler: Schedule changed.` Bulk writes such as queryset `update()` don't trigger that, so the [README](https://github.com/celery/django-celery-beat/blob/v2.9.0/README.rst) says to call `PeriodicTasks.update_changed()`; 2.9.0 also reloads everything every five minutes ([schedulers.py](https://github.com/celery/django-celery-beat/blob/v2.9.0/django_celery_beat/schedulers.py#L519-L531)). The database scheduler doesn't reset when the time zone changes; the README's fix makes every task start as if it had never run:

```python
from django_celery_beat.models import PeriodicTask, PeriodicTasks

PeriodicTask.objects.all().update(last_run_at=None)
PeriodicTasks.update_changed()
```

## Celery Beat Running Twice

"You have to ensure only a single scheduler is running for a schedule at a time, otherwise you'd end up with duplicate tasks" ([periodic tasks](https://docs.celeryq.dev/en/v5.6.3/userguide/periodic-tasks.html)); beat has no leader election. Log `self.request.id` in the task: a task sent twice runs under two task IDs, while a redelivery runs one task ID twice.

### Two Beats Are Sending

The usual sources are `worker -B` on more than one worker, `-B` alongside a standalone beat, a beat Deployment with more than one replica, and rollouts. A Kubernetes Deployment's default `RollingUpdate` sets `maxSurge` to 25%, rounded up, so with one replica the new beat starts before the old one stops ([Deployments](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)). `Recreate` terminates the old pod first:

```yaml
spec:
  replicas: 1
  strategy:
    type: Recreate
```

That covers upgrades only; a pod you delete by hand is replaced "immediately (even if the old Pod is still in a Terminating state)." For standby beats, use a scheduler with a lock. RedBeat 2.4.2 keeps the schedule in Redis, and a second beat blocks at startup until the active one's lock is free. A clean shutdown releases it; after a crash, the standby takes over only when the lock expires, after `redbeat_lock_timeout` (1,500 seconds by default) ([config](https://github.com/sibson/redbeat/blob/v2.4.2/docs/config.rst), [schedulers.py](https://github.com/sibson/redbeat/blob/v2.4.2/redbeat/schedulers.py#L634-L658)). [Distributed cron](https://cronradar.com/blog/distributed-cron) compares other ways to run a scheduler once.

### Beat Was Killed Before It Saved

The default scheduler writes run times every three minutes and on SIGTERM or SIGINT ([beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L241-L242), [apps/beat.py](https://github.com/celery/celery/blob/v5.6.3/celery/apps/beat.py#L155-L160)). A SIGKILL, from the out-of-memory killer or an orchestrator's stop timeout, loses up to three minutes of them, so a task sent in that window is due again after the restart. `beat_sync_every = 1` saves after every send ([beat_sync_every](https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#beat-sync-every)).

### The Broker Delivered It Again

Redis and SQS redeliver a message not acknowledged within the visibility timeout: one hour by default on Redis, 30 minutes on SQS ([Redis](https://docs.celeryq.dev/en/v5.6.3/getting-started/backends-and-brokers/redis.html#redis-visibility-timeout), [SQS](https://docs.celeryq.dev/en/v5.6.3/getting-started/backends-and-brokers/sqs.html)). A task is normally acknowledged right before it runs, but with `task_acks_late`, after ([task_acks_late](https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#task-acks-late)), so a late-acked task that outlasts the timeout starts again on another worker. Raise the timeout above your longest task, changing the three settings the [Redis caveats](https://docs.celeryq.dev/en/v5.6.3/getting-started/backends-and-brokers/redis.html#redis-caveats) list.

### Workers Were Down and the Queue Filled

Beat keeps sending while workers are down, and they run every copy when they return. With `"options": {"expires": 3600}` on the entry, a worker marks a task older than that `REVOKED` instead of running it ([expiration](https://docs.celeryq.dev/en/v5.6.3/userguide/calling.html#expiration)). Whatever the cause, a task that's safe to run twice makes a duplicate harmless; [cron job running twice](https://cronradar.com/blog/cron-job-running-twice) shows how.

## Report Each Run From Inside the Task

Every cause above looks the same from outside: no run and no error. A check-in from the task body turns that into an alert whichever part broke, because a stopped beat, a dead worker, an unregistered name, and an unread queue all mean no `start` arrives.

There's no installable CronRadar SDK for Python or Celery (checked September 20, 2026) and no auto-discovery of beat schedules, so each periodic task reports with its own HTTP check-in to its own monitor. This standard-library helper has a five-second timeout and logs monitoring errors instead of raising them. Keep the key in `CRONRADAR_API_KEY`, with a separate CronRadar application and key per environment:

```python
# proj/cronradar.py
import base64
import logging
import os
import urllib.parse
import urllib.request

log = logging.getLogger(__name__)


def check_in(monitor_key, event, *, schedule, run_id=None, grace_period=None, message=None):
    """Report one lifecycle event to CronRadar. Never raises into the task."""
    params = {"schedule": schedule, "run_id": run_id, "gracePeriod": grace_period, "message": message}
    query = urllib.parse.urlencode(
        {name: value for name, value in params.items() if value is not None},
        quote_via=urllib.parse.quote,
    )
    url = f"https://cron.life/api/v1/ping/{urllib.parse.quote(monitor_key, safe='')}/{event}?{query}"
    try:
        token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
        request = urllib.request.Request(url, data=b"", method="POST")
        # Unlike add_header(), this header is not copied to a redirect.
        request.add_unredirected_header("Authorization", f"Basic {token}")
        with urllib.request.urlopen(request, timeout=5):
            pass
    except Exception as error:
        # A monitoring problem must never fail the task or change its result.
        log.warning("CronRadar %s for %s not recorded (%s)", event, monitor_key, type(error).__name__)
```

The task reports its start, then its completion or failure, and re-raises its own exception so Celery records the failure as usual:

```python
# billing/tasks.py
from celery import shared_task

from billing.invoices import send_due_invoices
from proj.cronradar import check_in

MONITOR_KEY = "send-invoices"
SCHEDULE = "0 6 * * *"  # The beat entry's crontab, in UTC
GRACE_PERIOD = 600  # Seconds


@shared_task(bind=True, soft_time_limit=900)
def send_invoices(self):
    run = {"schedule": SCHEDULE, "run_id": self.request.id, "grace_period": GRACE_PERIOD}
    check_in(MONITOR_KEY, "start", **run)
    try:
        sent = send_due_invoices()
    except Exception as error:
        # The exception type only: never arguments, payloads, or log output.
        check_in(MONITOR_KEY, "fail", message=type(error).__name__, **run)
        raise
    check_in(MONITOR_KEY, "complete", **run)
    return sent
```

The first check-in creates the monitor, and a deleted monitor comes back on the next run. `schedule` and `gracePeriod` apply only when a check-in creates the monitor; change them later in its settings. With `timezone = "UTC"`, building the beat entry with `crontab.from_string(SCHEDULE)` keeps the two in step.

- **No run arrives:** the overdue check runs once a minute. Once 06:00 UTC passes without a check-in, the monitor shows `warning`; once it's past the 600-second grace period and at least two minutes late, it goes `critical` and opens an incident, alerting by email, Slack, webhook, Discord, or PagerDuty.
- **The task raises:** `fail` alerts immediately. With `self.retry()` or `autoretry_for`, every failed attempt sends one; the signal handlers in [Celery task retries](https://cronradar.com/blog/celery-task-retries) report only the final failure, including a dead pool process and a retry that expired.
- **It hangs or its worker dies:** a `start` with no finish is flagged as stuck when the next expected time plus grace passes with no check-in, and the next run's `start` resets that window; CronRadar has no per-run time limit. `soft_time_limit` turns a hang into `SoftTimeLimitExceeded`, reported as `fail`, except on the gevent pool ([time limits](https://docs.celeryq.dev/en/v5.6.3/userguide/workers.html#time-limits)).
- **It runs twice:** CronRadar records both runs and doesn't alert on extra runs.

CronRadar reads `schedule` as five-field cron in UTC ([schedule syntax](https://docs.cronradar.com/schedules)), so translate each entry:

| Beat entry                                                     | `timezone`         | `schedule` to send                           |
| -------------------------------------------------------------- | ------------------ | -------------------------------------------- |
| `crontab(minute=0, hour=6)`                                    | `UTC`              | `0 6 * * *`                                  |
| `crontab(minute=30, hour=1, day_of_week=1)`                    | `Asia/Tokyo`       | `30 16 * * 0` (Sunday in UTC)                |
| `crontab(minute=0, hour=9, day_of_week=1, day_of_month="1-7")` | `UTC`              | `0 9 1-7 * 1` (both day fields must match)   |
| `crontab(minute=0, hour=6)`                                    | `America/New_York` | `0 10 * * *`, grace of an hour plus run time |
| `timedelta(hours=1)`                                           | Any                | None; switch to a crontab                    |

The New York row follows CronRadar's daylight-saving rule: send the earlier of the two UTC times, 10:00 in summer, with a grace period of at least an hour plus the run's usual duration. In winter the monitor shows `warning` for an hour each day before the run arrives.

## Frequently Asked Questions

### Why is Celery beat running tasks twice?

Usually two beats are running: `worker -B` on several workers, more than one beat replica, or old and new beats overlapping in a rolling deploy. A beat killed before saving its run times, and a broker redelivering a late-acknowledged task, also cause it. Log the task ID: two IDs mean the task was sent twice, one ID twice means redelivery.

### Do I need a separate Celery beat process?

Yes, in production. Beat only sends tasks and workers only run them, so run one `celery -A proj beat` process alongside your workers. `celery worker -B` combines them, but Celery recommends it only for a single worker node, since every `-B` worker runs its own beat.

### Where is the celerybeat-schedule file?

In the directory beat started in, as `celerybeat-schedule`, possibly with a suffix such as `.db`. Move it to a writable volume with `celery beat -s` or `beat_schedule_filename`. Deleting it resets every entry's last run time; django-celery-beat and RedBeat don't use it.

### Why does Celery beat send tasks but nothing runs?

The message reached the broker and no worker ran it. Either no worker consumes the queue the task routes to, which `celery -A proj inspect active_queues` shows, or the workers don't know the task and log `Received unregistered task`.

### Does Celery beat catch up missed runs?

Partly. The default scheduler sends each overdue entry once when beat restarts, if its schedule file survived, and `beat_cron_starting_deadline` limits how far back a crontab looks. Tasks queued while workers were down all run unless they set `expires`. [Missed runs after downtime](https://cronradar.com/blog/missed-runs-after-downtime) compares other schedulers.
