diff options
-rw-r--r-- | config/config.sample.php | 11 | ||||
-rw-r--r-- | cron.php | 28 | ||||
-rw-r--r-- | db_structure.xml | 17 | ||||
-rw-r--r-- | lib/private/AppFramework/Db/Db.php | 17 | ||||
-rw-r--r-- | lib/private/BackgroundJob/JobList.php | 86 | ||||
-rw-r--r-- | lib/private/DB/Adapter.php | 20 | ||||
-rw-r--r-- | lib/private/DB/AdapterMySQL.php | 12 | ||||
-rw-r--r-- | lib/private/DB/AdapterSqlite.php | 12 | ||||
-rw-r--r-- | lib/private/DB/Connection.php | 32 | ||||
-rw-r--r-- | lib/private/Server.php | 6 | ||||
-rw-r--r-- | lib/public/BackgroundJob/IJobList.php | 11 | ||||
-rw-r--r-- | lib/public/IDBConnection.php | 19 | ||||
-rw-r--r-- | tests/lib/BackgroundJob/JobListTest.php | 126 |
13 files changed, 257 insertions, 140 deletions
diff --git a/config/config.sample.php b/config/config.sample.php index a2bc8e4343c..f3c1845c5c2 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -615,17 +615,6 @@ $CONFIG = array( 'cron_log' => true, /** - * Location of the lock file for cron executions can be specified here. - * Default is within the tmp directory. The file is named in the following way: - * owncloud-server-$INSTANCEID-cron.lock - * where $INSTANCEID is the string specified in the ``instanceid`` field. - * Because the cron lock file is accessed at regular intervals, it may prevent - * enabled disk drives from spinning down. A different location for this file - * can solve such issues. - */ -'cron.lockfile.location' => '', - -/** * Enables log rotation and limits the total size of logfiles. The default is 0, * or no rotation. Specify a size in bytes, for example 104857600 (100 megabytes * = 100 * 1024 * 1024 bytes). A new logfile is created with a new name when the @@ -100,34 +100,11 @@ try { } } - $instanceId = $config->getSystemValue('instanceid'); - $lockFileName = 'owncloud-server-' . $instanceId . '-cron.lock'; - $lockDirectory = $config->getSystemValue('cron.lockfile.location', sys_get_temp_dir()); - $lockDirectory = rtrim($lockDirectory, '\\/'); - $lockFile = $lockDirectory . '/' . $lockFileName; - - if (!file_exists($lockFile)) { - touch($lockFile); - } - // We call ownCloud from the CLI (aka cron) if ($appMode != 'cron') { \OCP\BackgroundJob::setExecutionType('cron'); } - // open the file and try to lock it. If it is not locked, the background - // job can be executed, otherwise another instance is already running - $fp = fopen($lockFile, 'w'); - $isLocked = flock($fp, LOCK_EX|LOCK_NB, $wouldBlock); - - // check if backgroundjobs is still running. The wouldBlock check is - // needed on systems with advisory locking, see - // http://php.net/manual/en/function.flock.php#45464 - if (!$isLocked || $wouldBlock) { - echo "Another instance of cron.php is still running!" . PHP_EOL; - exit(1); - } - // Work $jobList = \OC::$server->getJobList(); @@ -138,6 +115,7 @@ try { $executedJobs = []; while ($job = $jobList->getNext()) { if (isset($executedJobs[$job->getId()])) { + $jobList->unlockJob($job); break; } @@ -154,10 +132,6 @@ try { } } - // unlock the file - flock($fp, LOCK_UN); - fclose($fp); - } else { // We call cron.php from some website if ($appMode == 'cron') { diff --git a/db_structure.xml b/db_structure.xml index 640edfbfd80..e535814b518 100644 --- a/db_structure.xml +++ b/db_structure.xml @@ -968,12 +968,29 @@ </field> <field> + <!-- timestamp when the job was executed the last time --> <name>last_run</name> <type>integer</type> <default></default> <notnull>false</notnull> </field> + <field> + <!-- timestamp when the job was checked if it needs execution the last time --> + <name>last_checked</name> + <type>integer</type> + <default></default> + <notnull>false</notnull> + </field> + + <field> + <!-- timestamp when the job was reserved the last time, 1 day timeout --> + <name>reserved_at</name> + <type>integer</type> + <default></default> + <notnull>false</notnull> + </field> + <index> <name>job_class_index</name> <field> diff --git a/lib/private/AppFramework/Db/Db.php b/lib/private/AppFramework/Db/Db.php index 0d17d7bc225..ab06d56cfd1 100644 --- a/lib/private/AppFramework/Db/Db.php +++ b/lib/private/AppFramework/Db/Db.php @@ -29,6 +29,7 @@ namespace OC\AppFramework\Db; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDb; use OCP\IDBConnection; +use OCP\PreConditionNotMetException; /** * @deprecated use IDBConnection directly, will be removed in ownCloud 10 @@ -157,13 +158,27 @@ class Db implements IDb { * @param array $updatePreconditionValues ensure values match preconditions (column name => value) * @return int number of new rows * @throws \Doctrine\DBAL\DBALException - * @throws PreconditionNotMetException + * @throws PreConditionNotMetException */ public function setValues($table, array $keys, array $values, array $updatePreconditionValues = []) { return $this->connection->setValues($table, $keys, $values, $updatePreconditionValues); } /** + * @inheritdoc + */ + public function lockTable($tableName) { + $this->connection->lockTable($tableName); + } + + /** + * @inheritdoc + */ + public function unlockTable() { + $this->connection->unlockTable(); + } + + /** * Start a transaction */ public function beginTransaction() { diff --git a/lib/private/BackgroundJob/JobList.php b/lib/private/BackgroundJob/JobList.php index 2429b830446..c84969776c2 100644 --- a/lib/private/BackgroundJob/JobList.php +++ b/lib/private/BackgroundJob/JobList.php @@ -25,27 +25,34 @@ namespace OC\BackgroundJob; use OCP\AppFramework\QueryException; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\IJobList; use OCP\AutoloadNotAllowedException; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; class JobList implements IJobList { - /** @var \OCP\IDBConnection */ + + /** @var IDBConnection */ protected $connection; - /** - * @var \OCP\IConfig $config - */ + /**@var IConfig */ protected $config; + /**@var ITimeFactory */ + protected $timeFactory; + /** - * @param \OCP\IDBConnection $connection - * @param \OCP\IConfig $config + * @param IDBConnection $connection + * @param IConfig $config + * @param ITimeFactory $timeFactory */ - public function __construct($connection, $config) { + public function __construct(IDBConnection $connection, IConfig $config, ITimeFactory $timeFactory) { $this->connection = $connection; $this->config = $config; + $this->timeFactory = $timeFactory; } /** @@ -71,6 +78,7 @@ class JobList implements IJobList { 'class' => $query->createNamedParameter($class), 'argument' => $query->createNamedParameter($argument), 'last_run' => $query->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'last_checked' => $query->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT), ]); $query->execute(); } @@ -167,45 +175,40 @@ class JobList implements IJobList { * @return IJob|null */ public function getNext() { - $lastId = $this->getLastJob(); - $query = $this->connection->getQueryBuilder(); $query->select('*') ->from('jobs') - ->where($query->expr()->lt('id', $query->createNamedParameter($lastId, IQueryBuilder::PARAM_INT))) - ->orderBy('id', 'DESC') + ->where($query->expr()->lte('reserved_at', $query->createNamedParameter($this->timeFactory->getTime() - 12 * 3600, IQueryBuilder::PARAM_INT))) + ->orderBy('last_checked', 'ASC') ->setMaxResults(1); + + $update = $this->connection->getQueryBuilder(); + $update->update('jobs') + ->set('reserved_at', $update->createNamedParameter($this->timeFactory->getTime())) + ->set('last_checked', $update->createNamedParameter($this->timeFactory->getTime())) + ->where($update->expr()->eq('id', $update->createParameter('jobid'))); + + $this->connection->lockTable('jobs'); $result = $query->execute(); $row = $result->fetch(); $result->closeCursor(); if ($row) { - $jobId = $row['id']; + $update->setParameter('jobid', $row['id']); + $update->execute(); + $this->connection->unlockTable(); + $job = $this->buildJob($row); - } else { - //begin at the start of the queue - $query = $this->connection->getQueryBuilder(); - $query->select('*') - ->from('jobs') - ->orderBy('id', 'DESC') - ->setMaxResults(1); - $result = $query->execute(); - $row = $result->fetch(); - $result->closeCursor(); - - if ($row) { - $jobId = $row['id']; - $job = $this->buildJob($row); - } else { - return null; //empty job list + + if ($job === null) { + // Background job from disabled app, try again. + return $this->getNext(); } - } - if (is_null($job)) { - $this->removeById($jobId); - return $this->getNext(); - } else { return $job; + } else { + $this->connection->unlockTable(); + return null; } } @@ -267,13 +270,30 @@ class JobList implements IJobList { * @param IJob $job */ public function setLastJob($job) { + $this->unlockJob($job); $this->config->setAppValue('backgroundjob', 'lastjob', $job->getId()); } /** + * Remove the reservation for a job + * + * @param IJob $job + */ + public function unlockJob($job) { + $query = $this->connection->getQueryBuilder(); + $query->update('jobs') + ->set('reserved_at', $query->expr()->literal(0, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($job->getId(), IQueryBuilder::PARAM_INT))); + $query->execute(); + } + + /** * get the id of the last ran job * * @return int + * @deprecated 9.1.0 - The functionality behind the value is deprecated, it + * only tells you which job finished last, but since we now allow multiple + * executors to run in parallel, it's not used to calculate the next job. */ public function getLastJob() { return (int) $this->config->getAppValue('backgroundjob', 'lastjob', 0); diff --git a/lib/private/DB/Adapter.php b/lib/private/DB/Adapter.php index 9522f768c88..bcced395cb7 100644 --- a/lib/private/DB/Adapter.php +++ b/lib/private/DB/Adapter.php @@ -58,6 +58,26 @@ class Adapter { } /** + * Create an exclusive read+write lock on a table + * + * @param string $tableName + * @since 9.1.0 + */ + public function lockTable($tableName) { + $this->conn->beginTransaction(); + $this->conn->executeUpdate('LOCK TABLE `' .$tableName . '` IN EXCLUSIVE MODE'); + } + + /** + * Release a previous acquired lock again + * + * @since 9.1.0 + */ + public function unlockTable() { + $this->conn->commit(); + } + + /** * Insert a row if the matching row does not exists. * * @param string $table The table name (will replace *PREFIX* with the actual prefix) diff --git a/lib/private/DB/AdapterMySQL.php b/lib/private/DB/AdapterMySQL.php index ab87c589747..8504fc74e0f 100644 --- a/lib/private/DB/AdapterMySQL.php +++ b/lib/private/DB/AdapterMySQL.php @@ -24,6 +24,18 @@ namespace OC\DB; class AdapterMySQL extends Adapter { + + /** + * @param string $tableName + */ + public function lockTable($tableName) { + $this->conn->executeUpdate('LOCK TABLES `' .$tableName . '` WRITE'); + } + + public function unlockTable() { + $this->conn->executeUpdate('UNLOCK TABLES'); + } + public function fixupStatement($statement) { $statement = str_replace(' ILIKE ', ' COLLATE utf8_general_ci LIKE ', $statement); return $statement; diff --git a/lib/private/DB/AdapterSqlite.php b/lib/private/DB/AdapterSqlite.php index 3466e0e1aac..cefb06ffac6 100644 --- a/lib/private/DB/AdapterSqlite.php +++ b/lib/private/DB/AdapterSqlite.php @@ -27,6 +27,18 @@ namespace OC\DB; class AdapterSqlite extends Adapter { + + /** + * @param string $tableName + */ + public function lockTable($tableName) { + $this->conn->executeUpdate('BEGIN EXCLUSIVE TRANSACTION'); + } + + public function unlockTable() { + $this->conn->executeUpdate('COMMIT TRANSACTION'); + } + public function fixupStatement($statement) { $statement = preg_replace('( I?LIKE \?)', '$0 ESCAPE \'\\\'', $statement); $statement = preg_replace('/`(\w+)` ILIKE \?/', 'LOWER($1) LIKE LOWER(?)', $statement); diff --git a/lib/private/DB/Connection.php b/lib/private/DB/Connection.php index 7cdc13a7c6d..5b7026db2f3 100644 --- a/lib/private/DB/Connection.php +++ b/lib/private/DB/Connection.php @@ -25,6 +25,7 @@ */ namespace OC\DB; + use Doctrine\DBAL\DBALException; use Doctrine\DBAL\Driver; use Doctrine\DBAL\Configuration; @@ -46,6 +47,8 @@ class Connection extends \Doctrine\DBAL\Connection implements IDBConnection { */ protected $adapter; + protected $lockedTable = null; + public function connect() { try { return parent::connect(); @@ -281,7 +284,7 @@ class Connection extends \Doctrine\DBAL\Connection implements IDBConnection { foreach ($values as $name => $value) { $updateQb->set($name, $updateQb->createNamedParameter($value, $this->getType($value))); } - $where = $updateQb->expr()->andx(); + $where = $updateQb->expr()->andX(); $whereValues = array_merge($keys, $updatePreconditionValues); foreach ($whereValues as $name => $value) { $where->add($updateQb->expr()->eq( @@ -302,6 +305,33 @@ class Connection extends \Doctrine\DBAL\Connection implements IDBConnection { } /** + * Create an exclusive read+write lock on a table + * + * @param string $tableName + * @throws \BadMethodCallException When trying to acquire a second lock + * @since 9.1.0 + */ + public function lockTable($tableName) { + if ($this->lockedTable !== null) { + throw new \BadMethodCallException('Can not lock a new table until the previous lock is released.'); + } + + $tableName = $this->tablePrefix . $tableName; + $this->lockedTable = $tableName; + $this->adapter->lockTable($tableName); + } + + /** + * Release a previous acquired lock again + * + * @since 9.1.0 + */ + public function unlockTable() { + $this->adapter->unlockTable(); + $this->lockedTable = null; + } + + /** * returns the error code and message as a string for logging * works with DoctrineException * @return string diff --git a/lib/private/Server.php b/lib/private/Server.php index a4294ee2c88..0b7b8f9e403 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -362,7 +362,11 @@ class Server extends ServerContainer implements IServerContainer { }); $this->registerService('JobList', function (Server $c) { $config = $c->getConfig(); - return new \OC\BackgroundJob\JobList($c->getDatabaseConnection(), $config); + return new \OC\BackgroundJob\JobList( + $c->getDatabaseConnection(), + $config, + new TimeFactory() + ); }); $this->registerService('Router', function (Server $c) { $cacheFactory = $c->getMemCacheFactory(); diff --git a/lib/public/BackgroundJob/IJobList.php b/lib/public/BackgroundJob/IJobList.php index 5a76ce1ba26..9e401e68419 100644 --- a/lib/public/BackgroundJob/IJobList.php +++ b/lib/public/BackgroundJob/IJobList.php @@ -93,10 +93,21 @@ interface IJobList { public function setLastJob($job); /** + * Remove the reservation for a job + * + * @param IJob $job + * @since 9.1.0 + */ + public function unlockJob($job); + + /** * get the id of the last ran job * * @return int * @since 7.0.0 + * @deprecated 9.1.0 - The functionality behind the value is deprecated, it + * only tells you which job finished last, but since we now allow multiple + * executors to run in parallel, it's not used to calculate the next job. */ public function getLastJob(); diff --git a/lib/public/IDBConnection.php b/lib/public/IDBConnection.php index 780fcd26364..4ecf01ca27e 100644 --- a/lib/public/IDBConnection.php +++ b/lib/public/IDBConnection.php @@ -125,6 +125,25 @@ interface IDBConnection { public function setValues($table, array $keys, array $values, array $updatePreconditionValues = []); /** + * Create an exclusive read+write lock on a table + * + * Important Note: Due to the nature how locks work on different DBs, it is + * only possible to lock one table at a time. You should also NOT start a + * transaction while holding a lock. + * + * @param string $tableName + * @since 9.1.0 + */ + public function lockTable($tableName); + + /** + * Release a previous acquired lock again + * + * @since 9.1.0 + */ + public function unlockTable(); + + /** * Start a transaction * @since 6.0.0 */ diff --git a/tests/lib/BackgroundJob/JobListTest.php b/tests/lib/BackgroundJob/JobListTest.php index 6eed804bc3c..b8dcb735a26 100644 --- a/tests/lib/BackgroundJob/JobListTest.php +++ b/tests/lib/BackgroundJob/JobListTest.php @@ -9,7 +9,7 @@ namespace Test\BackgroundJob; use OCP\BackgroundJob\IJob; -use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; use Test\TestCase; /** @@ -22,20 +22,31 @@ class JobListTest extends TestCase { /** @var \OC\BackgroundJob\JobList */ protected $instance; + /** @var \OCP\IDBConnection */ + protected $connection; + /** @var \OCP\IConfig|\PHPUnit_Framework_MockObject_MockObject */ protected $config; + /** @var \OCP\AppFramework\Utility\ITimeFactory|\PHPUnit_Framework_MockObject_MockObject */ + protected $timeFactory; + protected function setUp() { parent::setUp(); - $connection = \OC::$server->getDatabaseConnection(); - $this->clearJobsList($connection); - $this->config = $this->getMock('\OCP\IConfig'); - $this->instance = new \OC\BackgroundJob\JobList($connection, $this->config); + $this->connection = \OC::$server->getDatabaseConnection(); + $this->clearJobsList(); + $this->config = $this->getMock('OCP\IConfig'); + $this->timeFactory = $this->getMock('OCP\AppFramework\Utility\ITimeFactory'); + $this->instance = new \OC\BackgroundJob\JobList( + $this->connection, + $this->config, + $this->timeFactory + ); } - protected function clearJobsList(IDBConnection $connection) { - $query = $connection->getQueryBuilder(); + protected function clearJobsList() { + $query = $this->connection->getQueryBuilder(); $query->delete('jobs'); $query->execute(); } @@ -131,8 +142,6 @@ class JobListTest extends TestCase { $this->instance->add($job, $argument); $this->assertFalse($this->instance->has($job, 10)); - - $this->instance->remove($job, $argument); } public function testGetLastJob() { @@ -144,50 +153,65 @@ class JobListTest extends TestCase { $this->assertEquals(15, $this->instance->getLastJob()); } + protected function createTempJob($class, $argument, $reservedTime = 0, $lastChecked = 0) { + if ($lastChecked === 0) { + $lastChecked = time(); + } + + $query = $this->connection->getQueryBuilder(); + $query->insert('jobs') + ->values([ + 'class' => $query->createNamedParameter($class), + 'argument' => $query->createNamedParameter($argument), + 'last_run' => $query->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'last_checked' => $query->createNamedParameter($lastChecked, IQueryBuilder::PARAM_INT), + 'reserved_at' => $query->createNamedParameter($reservedTime, IQueryBuilder::PARAM_INT), + ]); + $query->execute(); + } + public function testGetNext() { $job = new TestJob(); - $this->instance->add($job, 1); - $this->instance->add($job, 2); + $this->createTempJob(get_class($job), 1, 0, 12345); + $this->createTempJob(get_class($job), 2, 0, 12346); $jobs = $this->getAllSorted(); + $savedJob1 = $jobs[0]; - $savedJob1 = $jobs[count($jobs) - 2]; - $savedJob2 = $jobs[count($jobs) - 1]; - - $this->config->expects($this->once()) - ->method('getAppValue') - ->with('backgroundjob', 'lastjob', 0) - ->will($this->returnValue($savedJob2->getId())); - + $this->timeFactory->expects($this->atLeastOnce()) + ->method('getTime') + ->willReturn(123456789); $nextJob = $this->instance->getNext(); $this->assertEquals($savedJob1, $nextJob); - - $this->instance->remove($job, 1); - $this->instance->remove($job, 2); } - public function testGetNextWrapAround() { + public function testGetNextSkipReserved() { $job = new TestJob(); - $this->instance->add($job, 1); - $this->instance->add($job, 2); + $this->createTempJob(get_class($job), 1, 123456789, 12345); + $this->createTempJob(get_class($job), 2, 0, 12346); - $jobs = $this->getAllSorted(); + $this->timeFactory->expects($this->atLeastOnce()) + ->method('getTime') + ->willReturn(123456789); + $nextJob = $this->instance->getNext(); - $savedJob1 = $jobs[count($jobs) - 2]; - $savedJob2 = $jobs[count($jobs) - 1]; + $this->assertEquals(get_class($job), get_class($nextJob)); + $this->assertEquals(2, $nextJob->getArgument()); + } - $this->config->expects($this->once()) - ->method('getAppValue') - ->with('backgroundjob', 'lastjob', 0) - ->will($this->returnValue($savedJob1->getId())); + public function testGetNextSkipNonExisting() { + $job = new TestJob(); + $this->createTempJob('\OC\Non\Existing\Class', 1, 0, 12345); + $this->createTempJob(get_class($job), 2, 0, 12346); + $this->timeFactory->expects($this->atLeastOnce()) + ->method('getTime') + ->willReturn(123456789); $nextJob = $this->instance->getNext(); - $this->assertEquals($savedJob2, $nextJob); - - $this->instance->remove($job, 1); - $this->instance->remove($job, 2); + $this->assertEquals(get_class($job), get_class($nextJob)); + $this->assertEquals(2, $nextJob->getArgument()); } /** @@ -203,8 +227,6 @@ class JobListTest extends TestCase { $addedJob = $jobs[count($jobs) - 1]; $this->assertEquals($addedJob, $this->instance->getById($addedJob->getId())); - - $this->instance->remove($job, $argument); } public function testSetLastRun() { @@ -223,33 +245,5 @@ class JobListTest extends TestCase { $this->assertGreaterThanOrEqual($timeStart, $addedJob->getLastRun()); $this->assertLessThanOrEqual($timeEnd, $addedJob->getLastRun()); - - $this->instance->remove($job); - } - - public function testGetNextNonExisting() { - $job = new TestJob(); - $this->instance->add($job, 1); - $this->instance->add('\OC\Non\Existing\Class'); - $this->instance->add($job, 2); - - $jobs = $this->getAllSorted(); - - $savedJob1 = $jobs[count($jobs) - 2]; - $savedJob2 = $jobs[count($jobs) - 1]; - - $this->config->expects($this->any()) - ->method('getAppValue') - ->with('backgroundjob', 'lastjob', 0) - ->will($this->returnValue($savedJob1->getId())); - - $this->instance->getNext(); - - $nextJob = $this->instance->getNext(); - - $this->assertEquals($savedJob2, $nextJob); - - $this->instance->remove($job, 1); - $this->instance->remove($job, 2); } } |