diff --git a/app/code/Magento/Cron/Cron/CleanOldJobs.php b/app/code/Magento/Cron/Cron/CleanOldJobs.php
new file mode 100644
index 0000000000000..d198ba8ec7ff8
--- /dev/null
+++ b/app/code/Magento/Cron/Cron/CleanOldJobs.php
@@ -0,0 +1,89 @@
+config->get(System::CONFIG_TYPE);
+ $maxLifetime = 0;
+
+ array_walk_recursive(
+ $fullConfig,
+ static function ($value, $key) use (&$maxLifetime) {
+ if ($key === ProcessCronQueueObserver::XML_PATH_HISTORY_SUCCESS
+ || $key === ProcessCronQueueObserver::XML_PATH_HISTORY_FAILURE
+ ) {
+ $maxLifetime = max($maxLifetime, (int) $value);
+ }
+ }
+ );
+
+ if ($maxLifetime === 0) {
+ // Something has gone wrong. Why are there no configuration values?
+ // Drop out now to avoid doing any damage to this already-broken installation.
+ return;
+ }
+
+ // The value stored in XML is in minutes, we want seconds.
+ $maxLifetime *= 60;
+
+ // Add one day to avoid removing items which are near their natural expiry anyway.
+ $maxLifetime += 86400;
+
+ /** @var Schedule $scheduleResource */
+ $scheduleResource = $this->scheduleFactory->create()->getResource();
+
+ $currentTime = $this->dateTime->gmtTimestamp();
+ $deleteBefore = $scheduleResource->getConnection()->formatDate($currentTime - $maxLifetime);
+
+ $this->retrier->execute(
+ function () use ($scheduleResource, $deleteBefore) {
+ $scheduleResource->getConnection()->delete(
+ $scheduleResource->getTable('cron_schedule'),
+ [
+ 'scheduled_at < ?' => $deleteBefore,
+ ]
+ );
+ },
+ $scheduleResource->getConnection()
+ );
+ }
+}
diff --git a/app/code/Magento/Cron/Test/Unit/Cron/CleanOldJobsTest.php b/app/code/Magento/Cron/Test/Unit/Cron/CleanOldJobsTest.php
new file mode 100644
index 0000000000000..4078fae26e25d
--- /dev/null
+++ b/app/code/Magento/Cron/Test/Unit/Cron/CleanOldJobsTest.php
@@ -0,0 +1,141 @@
+configMock = $this->getMockBuilder(Config::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->dateTimeMock = $this->getMockBuilder(DateTime::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->dateTimeMock->method('gmtTimestamp')
+ ->willReturn($this->time);
+
+ $this->retrierMock = $this->getMockForAbstractClass(DeadlockRetrierInterface::class);
+
+ $this->scheduleFactoryMock = $this->getMockBuilder(ScheduleFactory::class)
+ ->onlyMethods(['create'])
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->scheduleMock = $this->getMockBuilder(Schedule::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->scheduleResourceMock = $this->getMockBuilder(ScheduleResourceModel::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $this->scheduleMock
+ ->method('getResource')
+ ->willReturn($this->scheduleResourceMock);
+
+ $this->scheduleFactoryMock
+ ->method('create')
+ ->willReturn($this->scheduleMock);
+
+ $this->cleanOldJobs = new CleanOldJobs(
+ $this->configMock,
+ $this->dateTimeMock,
+ $this->retrierMock,
+ $this->scheduleFactoryMock
+ );
+ }
+
+ public function testSuccess(): void
+ {
+ $tableName = 'cron_schedule';
+
+ $this->configMock->expects($this->once())
+ ->method('get')
+ ->with('system')
+ ->willReturn([
+ 'history_success_lifetime' => 100,
+ 'history_failure_lifetime' => 200,
+ ]);
+
+ $connectionMock = $this->getMockForAbstractClass(AdapterInterface::class);
+
+ $connectionMock->expects($this->once())
+ ->method('delete')
+ ->with($tableName, [
+ 'scheduled_at < ?' => '$this->time - (86400 + (200 * 60))',
+ ]);
+
+ $connectionMock->method('formatDate')
+ ->willReturnMap([
+ [1501538400, true, '$this->time'],
+ [1501538200, true, '$this->time - 200'],
+ [1501526400, true, '$this->time - (200 * 60)'],
+ [1501452000, true, '$this->time - 86400'],
+ [1501451800, true, '$this->time - (86400 + 200)'],
+ [1501440000, true, '$this->time - (86400 + (200 * 60))'],
+ ]);
+
+ $this->scheduleResourceMock->expects($this->once())
+ ->method('getTable')
+ ->with($tableName)
+ ->willReturn($tableName);
+ $this->scheduleResourceMock->expects($this->exactly(3))
+ ->method('getConnection')
+ ->willReturn($connectionMock);
+
+ $this->retrierMock->expects($this->once())
+ ->method('execute')
+ ->willReturnCallback(
+ function ($callback) {
+ return $callback();
+ }
+ );
+
+ $this->cleanOldJobs->execute();
+ }
+
+ public function testNoActionWhenEmptyConfig(): void
+ {
+ $this->configMock->expects($this->once())
+ ->method('get')
+ ->with('system')
+ ->willReturn([]);
+
+ $this->scheduleFactoryMock->expects($this->never())->method('create');
+
+ $this->cleanOldJobs->execute();
+ }
+}
diff --git a/app/code/Magento/Cron/composer.json b/app/code/Magento/Cron/composer.json
index 02f55e9de4597..c6e366227f688 100644
--- a/app/code/Magento/Cron/composer.json
+++ b/app/code/Magento/Cron/composer.json
@@ -7,11 +7,9 @@
"require": {
"php": "~8.1.0||~8.2.0||~8.3.0",
"magento/framework": "*",
+ "magento/module-config": "^100.1.2 || ^101.0",
"magento/module-store": "*"
},
- "suggest": {
- "magento/module-config": "*"
- },
"type": "magento2-module",
"license": [
"OSL-3.0",
diff --git a/app/code/Magento/Cron/etc/crontab.xml b/app/code/Magento/Cron/etc/crontab.xml
new file mode 100644
index 0000000000000..82e84e06979b3
--- /dev/null
+++ b/app/code/Magento/Cron/etc/crontab.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+ 0 0 * * *
+
+
+
diff --git a/app/code/Magento/Cron/etc/module.xml b/app/code/Magento/Cron/etc/module.xml
index 8112b9e8c46db..9babe267f0023 100644
--- a/app/code/Magento/Cron/etc/module.xml
+++ b/app/code/Magento/Cron/etc/module.xml
@@ -8,6 +8,7 @@
+