diff options
Diffstat (limited to 'lib/private/Lock/DBLockingProvider.php')
-rw-r--r-- | lib/private/Lock/DBLockingProvider.php | 240 |
1 files changed, 240 insertions, 0 deletions
diff --git a/lib/private/Lock/DBLockingProvider.php b/lib/private/Lock/DBLockingProvider.php new file mode 100644 index 00000000000..cb7f5cff350 --- /dev/null +++ b/lib/private/Lock/DBLockingProvider.php @@ -0,0 +1,240 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Lock; + +use OC\DB\QueryBuilder\Literal; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; + +/** + * Locking provider that stores the locks in the database + */ +class DBLockingProvider extends AbstractLockingProvider { + private array $sharedLocks = []; + + public function __construct( + private IDBConnection $connection, + private ITimeFactory $timeFactory, + int $ttl = 3600, + private bool $cacheSharedLocks = true, + ) { + parent::__construct($ttl); + } + + /** + * Check if we have an open shared lock for a path + */ + protected function isLocallyLocked(string $path): bool { + return isset($this->sharedLocks[$path]) && $this->sharedLocks[$path]; + } + + /** @inheritDoc */ + protected function markAcquire(string $path, int $targetType): void { + parent::markAcquire($path, $targetType); + if ($this->cacheSharedLocks) { + if ($targetType === self::LOCK_SHARED) { + $this->sharedLocks[$path] = true; + } + } + } + + /** + * Change the type of an existing tracked lock + */ + protected function markChange(string $path, int $targetType): void { + parent::markChange($path, $targetType); + if ($this->cacheSharedLocks) { + if ($targetType === self::LOCK_SHARED) { + $this->sharedLocks[$path] = true; + } elseif ($targetType === self::LOCK_EXCLUSIVE) { + $this->sharedLocks[$path] = false; + } + } + } + + /** + * Insert a file locking row if it does not exists. + */ + protected function initLockField(string $path, int $lock = 0): int { + $expire = $this->getExpireTime(); + return $this->connection->insertIgnoreConflict('file_locks', [ + 'key' => $path, + 'lock' => $lock, + 'ttl' => $expire + ]); + } + + protected function getExpireTime(): int { + return $this->timeFactory->getTime() + $this->ttl; + } + + /** @inheritDoc */ + public function isLocked(string $path, int $type): bool { + if ($this->hasAcquiredLock($path, $type)) { + return true; + } + $query = $this->connection->getQueryBuilder(); + $query->select('lock') + ->from('file_locks') + ->where($query->expr()->eq('key', $query->createNamedParameter($path))); + $result = $query->executeQuery(); + $lockValue = (int)$result->fetchOne(); + if ($type === self::LOCK_SHARED) { + if ($this->isLocallyLocked($path)) { + // if we have a shared lock we kept open locally but it's released we always have at least 1 shared lock in the db + return $lockValue > 1; + } else { + return $lockValue > 0; + } + } elseif ($type === self::LOCK_EXCLUSIVE) { + return $lockValue === -1; + } else { + return false; + } + } + + /** @inheritDoc */ + public function acquireLock(string $path, int $type, ?string $readablePath = null): void { + $expire = $this->getExpireTime(); + if ($type === self::LOCK_SHARED) { + if (!$this->isLocallyLocked($path)) { + $result = $this->initLockField($path, 1); + if ($result <= 0) { + $query = $this->connection->getQueryBuilder(); + $query->update('file_locks') + ->set('lock', $query->func()->add('lock', $query->createNamedParameter(1, IQueryBuilder::PARAM_INT))) + ->set('ttl', $query->createNamedParameter($expire)) + ->where($query->expr()->eq('key', $query->createNamedParameter($path))) + ->andWhere($query->expr()->gte('lock', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $result = $query->executeStatement(); + } + } else { + $result = 1; + } + } else { + $existing = 0; + if ($this->hasAcquiredLock($path, ILockingProvider::LOCK_SHARED) === false && $this->isLocallyLocked($path)) { + $existing = 1; + } + $result = $this->initLockField($path, -1); + if ($result <= 0) { + $query = $this->connection->getQueryBuilder(); + $query->update('file_locks') + ->set('lock', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) + ->set('ttl', $query->createNamedParameter($expire, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('key', $query->createNamedParameter($path))) + ->andWhere($query->expr()->eq('lock', $query->createNamedParameter($existing))); + $result = $query->executeStatement(); + } + } + if ($result !== 1) { + throw new LockedException($path, null, null, $readablePath); + } + $this->markAcquire($path, $type); + } + + /** @inheritDoc */ + public function releaseLock(string $path, int $type): void { + $this->markRelease($path, $type); + + // we keep shared locks till the end of the request so we can re-use them + if ($type === self::LOCK_EXCLUSIVE) { + $qb = $this->connection->getQueryBuilder(); + $qb->update('file_locks') + ->set('lock', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('key', $qb->createNamedParameter($path))) + ->andWhere($qb->expr()->eq('lock', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } elseif (!$this->cacheSharedLocks) { + $qb = $this->connection->getQueryBuilder(); + $qb->update('file_locks') + ->set('lock', $qb->func()->subtract('lock', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))) + ->where($qb->expr()->eq('key', $qb->createNamedParameter($path))) + ->andWhere($qb->expr()->gt('lock', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } + } + + /** @inheritDoc */ + public function changeLock(string $path, int $targetType): void { + $expire = $this->getExpireTime(); + if ($targetType === self::LOCK_SHARED) { + $qb = $this->connection->getQueryBuilder(); + $result = $qb->update('file_locks') + ->set('lock', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ->set('ttl', $qb->createNamedParameter($expire, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->andX( + $qb->expr()->eq('key', $qb->createNamedParameter($path)), + $qb->expr()->eq('lock', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) + ))->executeStatement(); + } else { + // since we only keep one shared lock in the db we need to check if we have more then one shared lock locally manually + if (isset($this->acquiredLocks['shared'][$path]) && $this->acquiredLocks['shared'][$path] > 1) { + throw new LockedException($path); + } + $qb = $this->connection->getQueryBuilder(); + $result = $qb->update('file_locks') + ->set('lock', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) + ->set('ttl', $qb->createNamedParameter($expire, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->andX( + $qb->expr()->eq('key', $qb->createNamedParameter($path)), + $qb->expr()->eq('lock', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ))->executeStatement(); + } + if ($result !== 1) { + throw new LockedException($path); + } + $this->markChange($path, $targetType); + } + + /** @inheritDoc */ + public function cleanExpiredLocks(): void { + $expire = $this->timeFactory->getTime(); + try { + $qb = $this->connection->getQueryBuilder(); + $qb->delete('file_locks') + ->where($qb->expr()->lt('ttl', $qb->createNamedParameter($expire, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } catch (\Exception $e) { + // If the table is missing, the clean up was successful + if ($this->connection->tableExists('file_locks')) { + throw $e; + } + } + } + + /** @inheritDoc */ + public function releaseAll(): void { + parent::releaseAll(); + + if (!$this->cacheSharedLocks) { + return; + } + // since we keep shared locks we need to manually clean those + $lockedPaths = array_keys($this->sharedLocks); + $lockedPaths = array_filter($lockedPaths, function ($path) { + return $this->sharedLocks[$path]; + }); + + $chunkedPaths = array_chunk($lockedPaths, 100); + + $qb = $this->connection->getQueryBuilder(); + $qb->update('file_locks') + ->set('lock', $qb->func()->subtract('lock', $qb->expr()->literal(1))) + ->where($qb->expr()->in('key', $qb->createParameter('chunk'))) + ->andWhere($qb->expr()->gt('lock', new Literal(0))); + + foreach ($chunkedPaths as $chunk) { + $qb->setParameter('chunk', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $qb->executeStatement(); + } + } +} |