Magento 2 cron stopped working – over 50,000 unexecuted jobs were backed up in the cron_schedule table. Transactional emails were not going out, indexers were not updating, pricing rules were not refreshing. The shop worked normally for customers but was frozen “from the inside”. Fix time: 3 hours.
Symptoms
- Order confirmation emails stopped arriving
- Catalog rule prices out of date
- No entries in
var/log/cron.logfor several days SELECT COUNT(*) FROM cron_schedule WHERE status = 'pending'returns 52,000- System cron entry for Magento is running (crontab -l shows entries)
Diagnosis
bin/magento cron:status SELECT status, COUNT(*) as cnt FROM cron_schedule GROUP BY status ORDER BY cnt DESC; SELECT job_code, status, scheduled_at, executed_at, finished_at, messages FROM cron_schedule WHERE status != 'pending' ORDER BY finished_at DESC LIMIT 20; ps aux | grep "magento cron"
Cause
One cron job (catalog_product_alert) was entering an infinite loop on a product with corrupted EAV data. The PHP process consumed 100% CPU for tens of minutes, then was killed by the OOM killer. Magento did not mark it as “failed” – the job stayed in “running” status forever. Subsequent cron runs saw the “running” job and did not start new instances.
Solution
bin/magento cron:unlock --all
-- Mark stuck "running" jobs as failed
UPDATE cron_schedule
SET status = 'error', messages = 'Manually unlocked - was stuck in running state'
WHERE status = 'running'
AND executed_at < DATE_SUB(NOW(), INTERVAL 2 HOUR);
-- Remove backed-up pending jobs
DELETE FROM cron_schedule
WHERE status = 'pending'
AND scheduled_at < DATE_SUB(NOW(), INTERVAL 1 DAY);
-- Remove old finished jobs
DELETE FROM cron_schedule
WHERE status IN ('success', 'error', 'missed')
AND finished_at < DATE_SUB(NOW(), INTERVAL 7 DAY);
<?php
class SafeCronJob
{
public function execute(): void
{
set_time_limit(300); // 5 minutes max
$lastId = $this->getCheckpoint();
$processed = 0;
while (true) {
$batch = $this->getBatch($lastId, 100);
if (empty($batch)) break;
foreach ($batch as $item) {
$this->processItem($item);
$lastId = $item->getId();
$processed++;
}
$this->saveCheckpoint($lastId);
if ($processed % 1000 === 0) {
sleep(1);
}
}
}
}
Takeaways
The cron_schedule table needs regular cleaning - without it grows to millions of records and itself slows down cron. One stuck job can block all others. Monitoring the number of pending jobs is fundamental - alert threshold: above 500 pending is a signal to investigate.
