DEV Community

Cover image for Cron Jobs in Magento 2: How to Adjust Schedules and Cron Groups Seamlessly
Vladyslav Podorozhnyi πŸ‡ΊπŸ‡¦ 🌻 for run_as_root GmbH

Posted on • Edited on

Cron Jobs in Magento 2: How to Adjust Schedules and Cron Groups Seamlessly

Introduction

Hello!

In today's article, I'll cover a topic that's not too tricky, but can be a bit annoying without some useful tips. Specifically, we'll dive into Magento 2 ( Mage-OS / Adobe Commerce ) to discuss cron jobs, their configurations, and the process of rescheduling them and altering their execution group.

You might wonder, why bother changing the schedule or group of a cron job? Let's find out.

Why Adjust the Cron Schedule?

Occasionally, a cron job might execute too frequently, leading to server load issues or even deadlocks. On the other hand, if it doesn't run often enough, you could be left waiting for emails that take an hour to send. This scenario becomes particularly tricky when these cron jobs are associated with third-party extensions.

Let's imagine we are using 3rd party Feed and Mailing extensions, and we are not OK with their cron jobs schedule.

How can you modify the execution patterns of these cron jobs without tampering with the third-party code directly? By implementing changes in your own codebase – your module. Let's explore this next.

Rescheduling Cron Jobs

Let's have a look at the cron job of the Feed extension feed_generation.

Here is its definition:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">
      ...
        <job name="feed_generation" instance="\Feed\Cron\FeedGenerator" method="execute">
            <schedule>* * * * *</schedule>
        </job>
      ...
    </group>
</config>
Enter fullscreen mode Exit fullscreen mode

Executing every minute, this job can impose a significant server load. We certainly don't need a new feed every 60 seconds!

To modify its frequency, we'll craft a module, Devto_ChangeFeedSchedule, and incorporate an etc/config.xml file within.

So, the structure would look like this:

Devto_ChangeFeedSchedule/
β”‚
β”œβ”€β”€ etc/
β”‚   β”œβ”€β”€ config.xml      # our config file
β”‚   └── module.xml      
β”‚
β”œβ”€β”€ registration.php    
β”‚
└── composer.json       
Enter fullscreen mode Exit fullscreen mode

The etc/config.xml should contain:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <crontab>
            <default> <!-- cron group -->
                <jobs>
                    <feed_generation> <!-- job name -->
                        <schedule>
                            <cron_expr>0 * * * *</cron_expr> <!-- new cron execution schedule -->
                        </schedule>
                    </feed_generation>
                </jobs>
            </default>
        </crontab>
    </default>
</config>
Enter fullscreen mode Exit fullscreen mode

And last but not least - add sequence into etc/module.xml to declare dependency over Feed module and configs apply order:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Devto_ChangeFeedSchedule" >
        <sequence>
            <!-- makes Devto_ChangeFeedSchedule configs be applied after Feed extension configs -->
            <module name="Vendor_FeedExtesnion"/>
        </sequence>
    </module>
</config>
Enter fullscreen mode Exit fullscreen mode

Once the module Devto_ChangeFeedSchedule is activated and the cache cleared, your new job schedule will take effect - run every hour but not a minute.

But what about cron groups? Let's delve into that next.

The Need to Change Cron Group

Imagine we're still working with the Feed and Mailing third-party extensions. These extensions utilize distinct cron groups, each operating as an isolated process. Thanks to this, cron groups can run concurrently/parallely, using individual processes.

Now, suppose these two cron jobs conflict, resulting in data inconsistencies in the Feed and Emails. Worse yet, deadlocks can crop up. You might ask, "Why the deadlocks?"

Well, imagine these extensions read & write the same table, say sales_order_item. When they run simultaneously, the stage is set for potential deadlocks. πŸ€·β€β™‚οΈ

If only these jobs were executed sequentially! By relocating them to a shared group, you can ensure they no longer run in parallel.

Altering the Cron Job's Group

A straightforward (though not perfect) solution would be to transfer the Mailing extension's cron job to the default group, where the Feed extension's job resides. Here's the Mailing cron job definition:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="mailing_extension"> <!-- mailing cron group -->
      ...
        <job name="send_mails_from_mailing_extension" instance="\Mailing\Cron\SendMailsCron" method="execute">
            <schedule>*/20 * * * *</schedule>
        </job>
      ...
    </group>
</config>
Enter fullscreen mode Exit fullscreen mode

However, a small, non-intuitive trick is essential here: Simply changing the group by redefining crontab.xml won't work. Instead, you need to craft a new cron job while deactivating the old one. This step is necessary since crontab.xml configurations (especially groups) can't be overridden, only augmented.

For instance, merely defining the send_mails_from_mailing_extension cron job in the default group would make it run in both the default and mailing_extension groups.

To disable send_mails_from_mailing_extension, an easy trick is to schedule it for February 30thβ€”a date that doesn't exist!

nerd fact πŸ€“: actually February 30 happened in Sweden in 1712

Following our earlier approach, we'd add the following content to the etc/config.xml in our module, Devto_ChangeMailingJobGroup:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <crontab>
            <default>
                <jobs>
                    <!-- Disable cron execution by scheduling it to Feb 30th. -->
                    <send_mails_from_mailing_extension>
                        <schedule>
                            <cron_expr>0 0 30 2 *</cron_expr>
                        </schedule>
                    </send_mails_from_mailing_extension>
                </jobs>
            </default>
        </crontab>
    </default>
</config>
Enter fullscreen mode Exit fullscreen mode

Next, define a NEW cron job in the default group:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default"> <!-- default cron group -->
      ...
        <!-- added devto prefix to cron name -->
        <job name="devto_send_mails_from_mailing_extension" instance="\Mailing\Cron\SendMailsCron" method="execute">
            <schedule>*/20 * * * *</schedule>
        </job>
      ...
    </group>
</config>
Enter fullscreen mode Exit fullscreen mode

And do not forget to add Mailing module sequence into etc/module.xml:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Devto_ChangeMailingJobGroup" >
        <sequence>
            <!-- makes Devto_ChangeMailingJobGroup configs be applied after Mailing extension configs -->
            <module name="Vendor_MailingExtesnion"/>
        </sequence>
    </module>
</config>
Enter fullscreen mode Exit fullscreen mode

After all changes, the module structure should look like this:

Devto_ChangeMailingJobGroup/
β”‚
β”œβ”€β”€ etc/
β”‚   β”œβ”€β”€ config.xml      # our config file
|   β”œβ”€β”€ crontab.xml     # our crontab definition file  
β”‚   └── module.xml      
β”‚
β”œβ”€β”€ registration.php    
β”‚
└── composer.json       
Enter fullscreen mode Exit fullscreen mode

Once Devto_ChangeMailingJobGroup is active and the cache refreshed, the send_mails_from_mailing_extension job will be disabled. Meanwhile, its counterpart, devto_send_mails_from_mailing_extension, will operate in the default cron group alongside the Feed cron job.

A Word of Caution on the Default Group

It's vital to recognize that Magento's native cron jobs already densely populate the default cron group. Overloading it might cause "traffic jams" if:

  1. Jobs have lengthy runtimes: If your tasks take too long to finish, they could end up waiting in line, slowing things down.
  2. Overfrequent Scheduling: Setting jobs to run too often can result in backlogs and potential overlaps.
  3. Overpopulation: Filling the group with too many tasks, even if they are quick, might overwhelm the system.

So, while the default group is convenient, treat it like a city's main highway during rush hour. It can handle a lot, but there's a limit before things get clogged. If you have too many heavy tasks or they're scheduled too frequently, you'll likely end up with delays.

In short, be mindful of the workload you're placing on the default group and consider creating your own cron groups to avoid conflict of processes.

Conclusion

And that's the lowdown on Magento's cron jobs, how to shuffle their schedules, and adjust their groups without diving into third-party code directly. With the knowledge you've gathered here, tweaking those pesky cron jobs should be much easier 😊

Thank you for reading!

Top comments (5)

Collapse
 
vpodorozh profile image
Vladyslav Podorozhnyi πŸ‡ΊπŸ‡¦ 🌻 run_as_root GmbH

UPD:

  • I've added a note regarding adding sequences into module.xml over modules configs we are aiming to change. Thx @dlmbr for noticing!
  • Fixed type in corn definitions closing tag - thx @abramchukm for noticing!
Collapse
 
novikor profile image
Maksym Novik

Nice that the article has focused on practical solutions and cautionary advice on overloading the default group. Usually, nobody cares and puts more and more jobs to the default, that's true.

A must-read for Magento developers looking to optimize cron job configurations.

Collapse
 
grzegorz_wysocki_586ac5a4 profile image
Grzegorz Wysocki • Edited

Keeping cron jobs in the same group doesn't ensure that they won't run in parallel. If some job in the group takes too much time to complete another iteration of cron:run set by magento 2 in system's crontab (which - when executed - spawns separate php process) will run cron jobs from the group again. The lagging job will be locked (magento locks jobs - not groups), but if there is another one in the group scheduled to run now (or some earlier job still within it's lifetime - defined in cron_groups.xml) it will be executed and will run in parallel with the lagging one.

There is no sure way to prevent such occurrences other than implementing custom locking mechanism.

Intoduction: there must be unique constraint on group_name column in our custom database table for insertOnDuplicate to work properly with this code. We use this mysql/mariadb feature to not bother creating this db entry in advance. This way it will be updated (if it already exists) or inserted (if it doesn't) - no need for some Data Patch. The resourceConnection object is just an instance of core m2 class: \Magento\Framework\App\ResourceConnection).

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="vendor_crongroup_lock">
        <column xsi:type="int"
                name="id"
                unsigned="true"
                nullable="false"
                identity="true"
                comment="ID"/>
        <column xsi:type="varchar"
                name="group_name"
                nullable="false"
                length="255"
                comment="Group Name"/>
        <column xsi:type="smallint"
                name="locked"
                unsigned="true"
                nullable="false"
                default="0"
                comment="Lock Flag"/>

        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="id"/>
        </constraint>

        <constraint xsi:type="unique" referenceId="VENDOR_CRONGROUP_LOCK_GROUP_NAME_UNIQUE_IDX">
            <column name="group_name" />
        </constraint>
    </table>
</schema>
Enter fullscreen mode Exit fullscreen mode
public function removeTempPngFromPdfFiles ()
    {
        // We don't really need a lock here, but we use it anyway to keep logs legible (this method is in no real
        // conflict with other cron jobs in this group, but may mess with logging by intruding own logs in-between)
        $this->dataCleanupHelper->enableLog();
        $removeLock = false;
        $connection = $this->resourceConnection->getConnection();
        try {
            if ($this->dataCleanupHelper->tempPngFromPdfFilesDirectoryExists()) {
                $connection->beginTransaction();
                try {

                    // Lock row for update
                    $select = $connection->select()->forUpdate();
                    $select
                        ->from(self::CRON_GROUP_LOCK_TABLE_NAME)
                        ->where('group_name = \'' . self::CRON_GROUP_NAME . '\' AND locked = 1');

                    $result = $connection->fetchAll($select);
                    if ($result) {
                        $connection->rollBack();
                        $this->dataCleanupHelper->log('........ - removeTempPngFromPdfFiles attempted to acquire lock but failed.');
                        return;
                    }

                    $connection->insertOnDuplicate(self::CRON_GROUP_LOCK_TABLE_NAME, [
                        ['group_name' => self::CRON_GROUP_NAME, 'locked' => 1]
                    ]);
                    $connection->commit();
                } catch (\Throwable $e) {
                    $connection->rollBack();
                    throw $e;
                }


                $startTime = time();
                $removeLock = true;
                try {
                    $this->dataCleanupHelper->log('[CRON][START]:  removeTempPngFromPdfFiles');

                    $this->dataCleanupHelper->removeTempPngFromPdfFiles();

                } catch (\Throwable $e) {
                    $this->dataCleanupHelper->log('[EXCEPTION]: ' . $e->getMessage(), 'error');
                } finally {
                    $duration = time() - $startTime;
                    $this->dataCleanupHelper->log('[CRON][END] - removeTempPngFromPdfFiles finished in ' . $duration . ' seconds');
                    $this->dataCleanupHelper->log('--------------------------');
                }
            }
        } catch (\Throwable $e) {
            $this->dataCleanupHelper->log(
                '[EXCEPTION]: Something went wrong when establishing lock. Message: ' . $e->getMessage(), 'error'
            );
            $this->dataCleanupHelper->log('--------------------------');
        } finally {
            if ($removeLock) {
                $connection->insertOnDuplicate(self::CRON_GROUP_LOCK_TABLE_NAME, [
                    ['group_name' => self::CRON_GROUP_NAME, 'locked' => 0]
                ]);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
grzegorz_wysocki_586ac5a4 profile image
Grzegorz Wysocki • Edited

Here is a log entry starting at a timestamp when multiple methods (all implementing custom locking) from the same cron group are scheduled (by coincidence of common multiple) to run at the same time. It's a real log entry with methods' names changed. The currentMethod is lagging, and in the meantime system's crontab runs m2 cron:run again after 1 minute. As a result all pending jobs from the group which were scheduled to run at timestamp, but haven't run yet because of the lagging job - are executed (they have 2 minute lifetime set):

[2025-08-22T12:00:03.785291+00:00] LOG.DEBUG: [CRON][START]: currentMethod [] []
[2025-08-22T12:00:03.787990+00:00] LOG.DEBUG: [SUB_JOB:STARTED]: Quote RX [] []
[2025-08-22T12:00:03.809908+00:00] LOG.DEBUG: No rx catalog with higher sort order than the last one processed found. [] []
[2025-08-22T12:00:03.810122+00:00] LOG.DEBUG: [SUB_JOB:STARTED]: Order RX [] []
[2025-08-22T12:00:03.984327+00:00] LOG.DEBUG: No rx catalog with higher sort order than the last one processed found. [] []
[2025-08-22T12:00:03.984560+00:00] LOG.DEBUG: [SUB_JOB:STARTED]: Customer RX [] []
[2025-08-22T12:00:04.212717+00:00] LOG.DEBUG: Starting from catalog with index: 31607. Catalog name: 315887 [] []
[2025-08-22T12:01:03.450277+00:00] LOG.DEBUG: ........ - someMethod1 attempted to acquire lock but failed. [] []
[2025-08-22T12:01:03.481900+00:00] LOG.DEBUG: ........ - someMethod2 attempted to acquire lock but failed. [] []
[2025-08-22T12:02:57.989842+00:00] LOG.DEBUG: Finishing on catalog with index: 31928. Catalog name: 317060 [] []
[2025-08-22T12:02:57.990298+00:00] LOG.DEBUG: [SUB_JOB][RESULTS] - Customer RX - Found: 404, Resized: 334, Saved Disk Space: 787.214 MB [] []
[2025-08-22T12:02:57.996725+00:00] LOG.DEBUG: [MAIN_JOB][RESULTS] - Found: 404, Resized: 334, Saved Disk Space: 787.214 MB [] []
[2025-08-22T12:02:57.999201+00:00] LOG.DEBUG: [CRON][END] - currentMethod finished in 174 seconds [] []
[2025-08-22T12:02:57.999303+00:00] LOG.DEBUG: -------------------------- [] []

Enter fullscreen mode Exit fullscreen mode
Collapse
 
olvajow profile image
OlvaJowDay

I haven't been working on Magento 2 for long, but I'm comfortable with my choice and also with the fact that I contacted Amasty for services. By the way, these specialists also provide a wide selection of various extensions, for example, magento 2 shipping table rates, which allow you to create the best website for your clients, completely covering their needs.