# Inquiry about how cyclic tasks are handled in Open edX

**URL:** <https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096>\
**Category:** Site Operators\
**Created:** [June 12, 2026, 2:08pm UTC](https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096 "2026-06-12T14:08:29Z")\
**Posts on this page:** 5\
**Page:** 1

<div class="post-metadata">

**Author:** ![jakubkl](https://sea2.discourse-cdn.com/flex020/user_avatar/discuss.openedx.org/jakubkl/32/9205_2.png) [@jakubkl](https://discuss.openedx.org/u/jakubkl)\
**Post date:** [June 12, 2026, 2:08pm UTC](https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096/1 "2026-06-12T14:08:29Z")

</div>

Hey everyone,

I’ve recently been informed about a bug with the platform I’m working on. Namely, planned emails from the **Communications MFE** weren’t being sent out.

So I scoured through edx-platform’s code and realized that the Scheduled Tasks are just saved in the DB as objects and the function - `process_scheduled_instructor_tasks` found in **lms/djangoapps/instructor\_task/api.py** submits them accordingly.

This function however, from what I see can ONLY be called from a django management command?!

Am I missing something or is Open edX shipped with this but requires the platform operator to wire some kind of beat service to that? I later found this related page (and another post on here regarding why celery beat was not chosen):

- [Processing Scheduled Instructor Tasks — edx-platform documentation](https://docs.openedx.org/projects/edx-platform/en/latest/references/docs/lms/djangoapps/instructor_task/docs/decisions/002-processing-scheduled-instructor-tasks.html)

I am just surprised that this feature is shipped with a platform as a silent failure without explicit warnings.

I already set up celery beat for some other custom parts of the platform and am planning to wire it together with this function but I am looking for a word on whether I missed some config tutorial or this is just not stated.

---

<div class="post-metadata">

**Author:** ![sarina](https://sea2.discourse-cdn.com/flex020/user_avatar/discuss.openedx.org/sarina/32/2308_2.png) [@sarina](https://discuss.openedx.org/u/sarina)\
**Post date:** [June 15, 2026, 2:49pm UTC](https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096/2 "2026-06-15T14:49:51Z")

</div>

@dave - do you know anything here, or could tag someone else in?

---

<div class="post-metadata">

**Author:** ![dave](https://sea2.discourse-cdn.com/flex020/user_avatar/discuss.openedx.org/dave/32/263_2.png) [@dave](https://discuss.openedx.org/u/dave)\
**Post date:** [June 15, 2026, 8:00pm UTC](https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096/3 "2026-06-15T20:00:22Z")

</div>

Maybe @omar would know about this area?

---

<div class="post-metadata">

**Author:** ![omar](https://sea2.discourse-cdn.com/flex020/user_avatar/discuss.openedx.org/omar/32/7275_2.png) [@omar](https://discuss.openedx.org/u/omar)\
**Post date:** [July 6, 2026, 8:48pm UTC](https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096/4 "2026-07-06T20:48:23Z")

</div>

I haven’t seen this new feature.

This feature has traces back to this pull request which has been done by the 2U/edX team in 2022:

- [feat: Add management command for processing scheduled instructor tasks by justinhynes · Pull Request #30448 · openedx/openedx-platform · GitHub](https://github.com/openedx/openedx-platform/pull/30448)
- [doc: introduces ADRs describing an approach for the scheduled instructor tasks backend implementation by justinhynes · Pull Request #29908 · openedx/openedx-platform · GitHub](https://github.com/openedx/openedx-platform/pull/29908)

Not sure who’s still there in 2U from the original contributors, but it’s worth a ping.

@jhynes if you’re still active here, please take a look.

---

<div class="post-metadata">

**Author:** ![jakubkl](https://sea2.discourse-cdn.com/flex020/user_avatar/discuss.openedx.org/jakubkl/32/9205_2.png) [@jakubkl](https://discuss.openedx.org/u/jakubkl)\
**Post date:** [August 3, 2026, 11:35pm UTC](https://discuss.openedx.org/t/inquiry-about-how-cyclic-tasks-are-handled-in-open-edx/19096/5 "2026-08-03T23:35:29Z")

</div>

Well it seems like this inquiry has kind of lost it’s momentum so I’m just gonna post my minimal solution below in case anyone else in the future comes across this issue.

It’s resolved by two plugins.

1. Tutor plugin on the host adding a celery beat service:

Structure:

```auto
├── pyproject.toml
└── src
    ├── compose-dev.yml
    ├── compose-prod.yml
    ├── plugin.py

```

pyproject.toml

```auto
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "instructor_tasks"
version = "1.0.0"
description = "Process scheduled instructor tasks"

[project.entry-points."tutor.plugin.v1"]
instructor_tasks = "src.plugin"

[tool.setuptools.package-data]
instructor_tasks = [
    "src/*.yml",
]

[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]

```

plugin.py

```auto
from pathlib import Path
from tutor import hooks

COMPOSE_DEV = "compose-dev.yml"
COMPOSE_PROD = "compose-prod.yml"

DIR = Path( __file__ ).resolve().parent

compose_dev_str = (DIR / COMPOSE_DEV).read_text()
compose_prod_str = (DIR / COMPOSE_PROD).read_text()

hooks.Filters.ENV_PATCHES.add_item(
    ("local-docker-compose-dev-services", compose_dev_str)
)

hooks.Filters.ENV_PATCHES.add_item(
    ("local-docker-compose-prod-services", compose_prod_str)
)

```

compose-dev.yml

```auto
lms-beat:
    image: {{ DOCKER_IMAGE_OPENEDX_DEV }}
    environment:
      SERVICE_VARIANT: lms
      DJANGO_SETTINGS_MODULE: lms.envs.tutor.development
    command:
      - "celery"
      - "--app=lms.celery"
      - "beat"
      - "--loglevel=info"
    restart: unless-stopped
    volumes:
      - ../apps/openedx/settings/lms:/openedx/edx-platform/lms/envs/tutor:ro
      - ../apps/openedx/settings/cms:/openedx/edx-platform/cms/envs/tutor:ro
      - ../apps/openedx/config:/openedx/config:ro
      - ../../data/lms:/openedx/data
      - ../../data/openedx-media:/openedx/media
      - ../../data/openedx-media-private:/openedx/media-private
      {%- for mount in iter_mounts(MOUNTS, "openedx", "lms-beat") %}
      - {{ mount }}
      {%- endfor %}
    depends_on:
      - lms-worker
      - lms

```

compose-prod.yml

```auto
lms-beat:
    image: {{ DOCKER_IMAGE_OPENEDX }}
    environment:
      SERVICE_VARIANT: lms
      DJANGO_SETTINGS_MODULE: lms.envs.tutor.production
    command:
      - "celery"
      - "--app=lms.celery"
      - "beat"
      - "--loglevel=info"
    restart: unless-stopped
    volumes:
      - ../apps/openedx/settings/lms:/openedx/edx-platform/lms/envs/tutor:ro
      - ../apps/openedx/settings/cms:/openedx/edx-platform/cms/envs/tutor:ro
      - ../apps/openedx/config:/openedx/config:ro
      - ../../data/lms:/openedx/data
      - ../../data/openedx-media:/openedx/media
      - ../../data/openedx-media-private:/openedx/media-private
      {%- for mount in iter_mounts(MOUNTS, "openedx", "lms-beat") %}
      - {{ mount }}
      {%- endfor %}
    depends_on:
      - lms-worker
      - lms

```

* * *

1. Django app plugin linking a custom task to the function that is invoked from the management command plus updating the beat config upon django launch during the settings phase.

Structure:

```auto
├── instructor_tasks
│ ├── apps.py
│ ├── __init__.py
│ ├── settings.py
│ └── tasks.py
└── pyproject.toml

```

pyproject.toml

```auto
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "instructor_tasks"
version = "1.0.0"
description = "Process scheduled instructor tasks"

[project.entry-points."lms.djangoapp"]
instructor_tasks = "instructor_tasks.apps:InstructorTasksConfig"

[tool.setuptools.packages.find]
where = ["."]
include = ["instructor_tasks*"]

```

apps.py

```auto
import logging
from django.apps import AppConfig

log = logging.getLogger( __name__ )

class InstructorTasksConfig(AppConfig):
    name = "instructor_tasks"
    verbose_name = "Instructor Task"

    plugin_app = {
        'settings_config':{
            'lms.djangoapp':{
                'common':{'relative_path':'settings'}
            }
        }
    }

    def ready(self):
        log.info("Test, the app is working!")

```

settings.py

```auto
from celery.schedules import crontab

def plugin_settings(settings):
   settings.CELERYBEAT_SCHEDULE.update({
      'call_process_scheduled_instructor_tasks': {
         'task': 'instructor_tasks.tasks.call_process_scheduled_instructor_tasks',
         'schedule' : crontab(), # no args - every minute just what we want as granularity of mail scheduling is 1min
         'options' : {'queue' : 'edx.lms.core.default'}
      },
   })

```

tasks.py

```auto
import datetime
import logging

import pytz
from celery import shared_task
from django.db import transaction
from lms.djangoapps.instructor_task.api_helper import (
    QueueConnectionError,
    submit_scheduled_task,
)
from lms.djangoapps.instructor_task.models import (
    QUEUING,
    SCHEDULED,
    InstructorTaskSchedule,
)

log = logging.getLogger( __name__ )

@shared_task
def call_process_scheduled_instructor_tasks():
    log.info("Calling process_scheduled_instructor_tasks")

    with transaction.atomic():
        now = datetime.datetime.now(pytz.utc)
        due_schedules = InstructorTaskSchedule.objects.filter(
            task__task_state=SCHEDULED,
            task_due__lte=now,
        )

        log.info(f"Found {due_schedules.count()} scheduled instructor tasks")

        for schedule in due_schedules:
            try:
                log.info(f"Queueing task {schedule.task.id}")
                submit_scheduled_task(schedule)

                if schedule.task.task_state == SCHEDULED:
                    schedule.task.task_state = QUEUING
                    schedule.task.save(update_fields=["task_state"])
                    log.info(f"Updated task {schedule.task.id} state to {QUEUING}")

            except QueueConnectionError as exc:
                log.error(f"Error processing task {schedule.task.id}: {exc}")

```

Both plugins as .tar.gz below

[plugins.tar.gz](https://discuss.openedx.org/uploads/short-url/cp8lqYHjGRKB77krZQYD53II11.gz) (2.0 KB)

* * *

I am sure this could be solved in a better way. The current solution is not ideal because it requires a task to run every minute, which is wasteful. However, increasing the execution interval would require an MFE change to increase the time selection granularity in the UI accordingly.
