fix(propagator): Improve lock behavior of propagator

Fix possible dead locks when running the propagator caused by two
requests updating the same amount rows in transactions.

- Lock rows always in the same deterministic order by sorting the
  path_hash first

- On all database outside of sqlite, also do first a SELECT FOR UPDATE
  to lock all the rows used in batch UPDATE calls, afterward to decrease
  the risk of two requests trying to lock the same rows

Signed-off-by: Carl Schwan <carlschwan@kde.org>
This commit is contained in:
Carl Schwan 2026-02-06 12:09:18 +01:00
parent 234f052a3c
commit cae742d182
No known key found for this signature in database
GPG key ID: 02325448204E452A
9 changed files with 127 additions and 22 deletions

View file

@ -1,3 +1,4 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
@ -17,3 +18,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -317,6 +317,7 @@ return array(
'OCP\\DB\\IPreparedStatement' => $baseDir . '/lib/public/DB/IPreparedStatement.php',
'OCP\\DB\\IResult' => $baseDir . '/lib/public/DB/IResult.php',
'OCP\\DB\\ISchemaWrapper' => $baseDir . '/lib/public/DB/ISchemaWrapper.php',
'OCP\\DB\\QueryBuilder\\ConflictResolutionMode' => $baseDir . '/lib/public/DB/QueryBuilder/ConflictResolutionMode.php',
'OCP\\DB\\QueryBuilder\\ICompositeExpression' => $baseDir . '/lib/public/DB/QueryBuilder/ICompositeExpression.php',
'OCP\\DB\\QueryBuilder\\IExpressionBuilder' => $baseDir . '/lib/public/DB/QueryBuilder/IExpressionBuilder.php',
'OCP\\DB\\QueryBuilder\\IFunctionBuilder' => $baseDir . '/lib/public/DB/QueryBuilder/IFunctionBuilder.php',

View file

@ -358,6 +358,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\DB\\IPreparedStatement' => __DIR__ . '/../../..' . '/lib/public/DB/IPreparedStatement.php',
'OCP\\DB\\IResult' => __DIR__ . '/../../..' . '/lib/public/DB/IResult.php',
'OCP\\DB\\ISchemaWrapper' => __DIR__ . '/../../..' . '/lib/public/DB/ISchemaWrapper.php',
'OCP\\DB\\QueryBuilder\\ConflictResolutionMode' => __DIR__ . '/../../..' . '/lib/public/DB/QueryBuilder/ConflictResolutionMode.php',
'OCP\\DB\\QueryBuilder\\ICompositeExpression' => __DIR__ . '/../../..' . '/lib/public/DB/QueryBuilder/ICompositeExpression.php',
'OCP\\DB\\QueryBuilder\\IExpressionBuilder' => __DIR__ . '/../../..' . '/lib/public/DB/QueryBuilder/IExpressionBuilder.php',
'OCP\\DB\\QueryBuilder\\IFunctionBuilder' => __DIR__ . '/../../..' . '/lib/public/DB/QueryBuilder/IFunctionBuilder.php',

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OC\DB\QueryBuilder;
use OCP\DB\IResult;
use OCP\DB\QueryBuilder\ConflictResolutionMode;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
@ -290,4 +291,9 @@ abstract class ExtendedQueryBuilder implements IQueryBuilder {
public function prefixTableName(string $table): string {
return $this->builder->prefixTableName($table);
}
public function forUpdate(ConflictResolutionMode $conflictResolutionMode = ConflictResolutionMode::Ordinary): self {
$this->builder->forUpdate($conflictResolutionMode);
return $this;
}
}

View file

@ -19,12 +19,14 @@ use OC\DB\QueryBuilder\FunctionBuilder\PgSqlFunctionBuilder;
use OC\DB\QueryBuilder\FunctionBuilder\SqliteFunctionBuilder;
use OC\SystemConfig;
use OCP\DB\IResult;
use OCP\DB\QueryBuilder\ConflictResolutionMode;
use OCP\DB\QueryBuilder\ICompositeExpression;
use OCP\DB\QueryBuilder\ILiteral;
use OCP\DB\QueryBuilder\IParameter;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\QueryBuilder\IQueryFunction;
use OCP\IDBConnection;
use Override;
use Psr\Log\LoggerInterface;
class QueryBuilder implements IQueryBuilder {
@ -1379,4 +1381,12 @@ class QueryBuilder implements IQueryBuilder {
return $this;
}
#[Override]
public function forUpdate(ConflictResolutionMode $conflictResolutionMode = ConflictResolutionMode::Ordinary): self {
match ($conflictResolutionMode) {
ConflictResolutionMode::Ordinary => $this->queryBuilder->forUpdate(),
ConflictResolutionMode::SkipLocked => $this->queryBuilder->forUpdate(\Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode::SKIP_LOCKED),
};
return $this;
}
}

View file

@ -59,6 +59,7 @@ class Propagator implements IPropagator {
}
$parentHashes = array_map('md5', $parents);
sort($parentHashes); // Ensure rows are always locked in the same order
$etag = uniqid(); // since we give all folders the same etag we don't ask the storage for the etag
$builder = $this->connection->getQueryBuilder();
@ -160,36 +161,87 @@ class Propagator implements IPropagator {
}
$this->inBatch = false;
// Ensure rows are always locked in the same order
uasort($this->batch, static fn (array $a, array $b) => $a['hash'] <=> $b['hash']);
try {
$this->connection->beginTransaction();
$query = $this->connection->getQueryBuilder();
$storageId = $this->storage->getCache()->getNumericStorageId();
$query->update('filecache')
->set('mtime', $query->func()->greatest('mtime', $query->createParameter('time')))
->set('etag', $query->expr()->literal(uniqid()))
->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('path_hash', $query->createParameter('hash')));
if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_SQLITE) {
// Lock the rows before updating then with a SELECT FOR UPDATE
// The select also allow us to fetch the fileid and then use these in the UPDATE
// queries as a faster lookup than the path_hash
$hashes = array_map(static fn (array $a): string => $a['hash'], $this->batch);
$sizeQuery = $this->connection->getQueryBuilder();
$sizeQuery->update('filecache')
->set('size', $sizeQuery->func()->add('size', $sizeQuery->createParameter('size')))
->where($query->expr()->eq('storage', $sizeQuery->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('path_hash', $sizeQuery->createParameter('hash')))
->andWhere($sizeQuery->expr()->gt('size', $sizeQuery->createNamedParameter(-1, IQueryBuilder::PARAM_INT)));
foreach (array_chunk($hashes, 1000) as $hashesChunk) {
$query = $this->connection->getQueryBuilder();
$result = $query->select('fileid', 'path', 'path_hash', 'size')
->from('filecache')
->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->in('path_hash', $query->createNamedParameter($hashesChunk, IQueryBuilder::PARAM_STR_ARRAY)))
->orderBy('path_hash')
->forUpdate()
->executeQuery();
foreach ($this->batch as $item) {
$query->setParameter('time', $item['time'], IQueryBuilder::PARAM_INT);
$query->setParameter('hash', $item['hash']);
$query = $this->connection->getQueryBuilder();
$query->update('filecache')
->set('mtime', $query->func()->greatest('mtime', $query->createParameter('time')))
->set('etag', $query->expr()->literal(uniqid()))
->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('fileid', $query->createParameter('fileid')));
$query->executeStatement();
$queryWithSize = $this->connection->getQueryBuilder();
$queryWithSize->update('filecache')
->set('mtime', $queryWithSize->func()->greatest('mtime', $queryWithSize->createParameter('time')))
->set('etag', $queryWithSize->expr()->literal(uniqid()))
->set('size', $queryWithSize->func()->add('size', $queryWithSize->createParameter('size')))
->where($queryWithSize->expr()->eq('storage', $queryWithSize->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($queryWithSize->expr()->eq('fileid', $queryWithSize->createParameter('fileid')));
if ($item['size']) {
$sizeQuery->setParameter('size', $item['size'], IQueryBuilder::PARAM_INT);
$sizeQuery->setParameter('hash', $item['hash']);
while ($row = $result->fetchAssociative()) {
$item = $this->batch[$row['path']];
if ($item['size'] && $row['size'] > -1) {
$queryWithSize->setParameter('fileid', $row['fileid'], IQueryBuilder::PARAM_INT)
->setParameter('size', $item['size'], IQueryBuilder::PARAM_INT)
->setParameter('time', $item['time'], IQueryBuilder::PARAM_INT)
->executeStatement();
} else {
$query->setParameter('fileid', $row['fileid'], IQueryBuilder::PARAM_INT)
->setParameter('time', $item['time'], IQueryBuilder::PARAM_INT)
->executeStatement();
}
}
}
} else {
// No FOR UPDATE support in Sqlite, but instead the whole table is locked
$query = $this->connection->getQueryBuilder();
$query->update('filecache')
->set('mtime', $query->func()->greatest('mtime', $query->createParameter('time')))
->set('etag', $query->expr()->literal(uniqid()))
->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('path_hash', $query->createParameter('hash')));
$sizeQuery->executeStatement();
$queryWithSize = $this->connection->getQueryBuilder();
$queryWithSize->update('filecache')
->set('mtime', $queryWithSize->func()->greatest('mtime', $queryWithSize->createParameter('time')))
->set('etag', $queryWithSize->expr()->literal(uniqid()))
->set('size', $queryWithSize->func()->add('size', $queryWithSize->createParameter('size')))
->where($queryWithSize->expr()->eq('storage', $queryWithSize->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($queryWithSize->expr()->eq('path_hash', $queryWithSize->createParameter('hash')));
foreach ($this->batch as $item) {
if ($item['size']) {
$queryWithSize->setParameter('hash', $item['hash'], IQueryBuilder::PARAM_STR)
->setParameter('time', $item['time'], IQueryBuilder::PARAM_INT)
->setParameter('size', $item['size'], IQueryBuilder::PARAM_INT)
->executeStatement();
} else {
$query->setParameter('hash', $item['hash'], IQueryBuilder::PARAM_STR)
->setParameter('time', $item['time'], IQueryBuilder::PARAM_INT)
->executeStatement();
}
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\DB\QueryBuilder;
/**
* Conflict resolution mode for "FOR UPDATE" select queries.
*
* @since 34.0.0
*/
enum ConflictResolutionMode {
/**
* Wait for the row to be unlocked.
*/
case Ordinary;
/**
* Skip the row if it is locked.
*/
case SkipLocked;
}

View file

@ -1092,4 +1092,12 @@ interface IQueryBuilder {
* @since 30.0.0
*/
public function getOutputColumns(): array;
/**
* Locks the queried rows for a subsequent update.
*
* @return $this
* @since 34.0.0
*/
public function forUpdate(ConflictResolutionMode $conflictResolutionMode = ConflictResolutionMode::Ordinary): self;
}

View file

@ -15,8 +15,7 @@ use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class PropagatorTest extends TestCase {
/** @var IStorage */
private $storage;
private IStorage $storage;
protected function setUp(): void {
parent::setUp();