DBLockingProvider.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OC\Lock;
  8. use OC\DB\QueryBuilder\Literal;
  9. use OCP\AppFramework\Utility\ITimeFactory;
  10. use OCP\DB\QueryBuilder\IQueryBuilder;
  11. use OCP\IDBConnection;
  12. use OCP\Lock\ILockingProvider;
  13. use OCP\Lock\LockedException;
  14. /**
  15. * Locking provider that stores the locks in the database
  16. */
  17. class DBLockingProvider extends AbstractLockingProvider {
  18. private array $sharedLocks = [];
  19. public function __construct(
  20. private IDBConnection $connection,
  21. private ITimeFactory $timeFactory,
  22. int $ttl = 3600,
  23. private bool $cacheSharedLocks = true,
  24. ) {
  25. parent::__construct($ttl);
  26. }
  27. /**
  28. * Check if we have an open shared lock for a path
  29. */
  30. protected function isLocallyLocked(string $path): bool {
  31. return isset($this->sharedLocks[$path]) && $this->sharedLocks[$path];
  32. }
  33. /** @inheritDoc */
  34. protected function markAcquire(string $path, int $targetType): void {
  35. parent::markAcquire($path, $targetType);
  36. if ($this->cacheSharedLocks) {
  37. if ($targetType === self::LOCK_SHARED) {
  38. $this->sharedLocks[$path] = true;
  39. }
  40. }
  41. }
  42. /**
  43. * Change the type of an existing tracked lock
  44. */
  45. protected function markChange(string $path, int $targetType): void {
  46. parent::markChange($path, $targetType);
  47. if ($this->cacheSharedLocks) {
  48. if ($targetType === self::LOCK_SHARED) {
  49. $this->sharedLocks[$path] = true;
  50. } elseif ($targetType === self::LOCK_EXCLUSIVE) {
  51. $this->sharedLocks[$path] = false;
  52. }
  53. }
  54. }
  55. /**
  56. * Insert a file locking row if it does not exists.
  57. */
  58. protected function initLockField(string $path, int $lock = 0): int {
  59. $expire = $this->getExpireTime();
  60. return $this->connection->insertIgnoreConflict('file_locks', [
  61. 'key' => $path,
  62. 'lock' => $lock,
  63. 'ttl' => $expire
  64. ]);
  65. }
  66. protected function getExpireTime(): int {
  67. return $this->timeFactory->getTime() + $this->ttl;
  68. }
  69. /** @inheritDoc */
  70. public function isLocked(string $path, int $type): bool {
  71. if ($this->hasAcquiredLock($path, $type)) {
  72. return true;
  73. }
  74. $query = $this->connection->getQueryBuilder();
  75. $query->select('lock')
  76. ->from('file_locks')
  77. ->where($query->expr()->eq('key', $query->createNamedParameter($path)));
  78. $result = $query->executeQuery();
  79. $lockValue = (int)$result->fetchOne();
  80. if ($type === self::LOCK_SHARED) {
  81. if ($this->isLocallyLocked($path)) {
  82. // 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
  83. return $lockValue > 1;
  84. } else {
  85. return $lockValue > 0;
  86. }
  87. } elseif ($type === self::LOCK_EXCLUSIVE) {
  88. return $lockValue === -1;
  89. } else {
  90. return false;
  91. }
  92. }
  93. /** @inheritDoc */
  94. public function acquireLock(string $path, int $type, ?string $readablePath = null): void {
  95. $expire = $this->getExpireTime();
  96. if ($type === self::LOCK_SHARED) {
  97. if (!$this->isLocallyLocked($path)) {
  98. $result = $this->initLockField($path, 1);
  99. if ($result <= 0) {
  100. $query = $this->connection->getQueryBuilder();
  101. $query->update('file_locks')
  102. ->set('lock', $query->func()->add('lock', $query->createNamedParameter(1, IQueryBuilder::PARAM_INT)))
  103. ->set('ttl', $query->createNamedParameter($expire))
  104. ->where($query->expr()->eq('key', $query->createNamedParameter($path)))
  105. ->andWhere($query->expr()->gte('lock', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
  106. $result = $query->executeStatement();
  107. }
  108. } else {
  109. $result = 1;
  110. }
  111. } else {
  112. $existing = 0;
  113. if ($this->hasAcquiredLock($path, ILockingProvider::LOCK_SHARED) === false && $this->isLocallyLocked($path)) {
  114. $existing = 1;
  115. }
  116. $result = $this->initLockField($path, -1);
  117. if ($result <= 0) {
  118. $query = $this->connection->getQueryBuilder();
  119. $query->update('file_locks')
  120. ->set('lock', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))
  121. ->set('ttl', $query->createNamedParameter($expire, IQueryBuilder::PARAM_INT))
  122. ->where($query->expr()->eq('key', $query->createNamedParameter($path)))
  123. ->andWhere($query->expr()->eq('lock', $query->createNamedParameter($existing)));
  124. $result = $query->executeStatement();
  125. }
  126. }
  127. if ($result !== 1) {
  128. throw new LockedException($path, null, null, $readablePath);
  129. }
  130. $this->markAcquire($path, $type);
  131. }
  132. /** @inheritDoc */
  133. public function releaseLock(string $path, int $type): void {
  134. $this->markRelease($path, $type);
  135. // we keep shared locks till the end of the request so we can re-use them
  136. if ($type === self::LOCK_EXCLUSIVE) {
  137. $qb = $this->connection->getQueryBuilder();
  138. $qb->update('file_locks')
  139. ->set('lock', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
  140. ->where($qb->expr()->eq('key', $qb->createNamedParameter($path)))
  141. ->andWhere($qb->expr()->eq('lock', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)));
  142. $qb->executeStatement();
  143. } elseif (!$this->cacheSharedLocks) {
  144. $qb = $this->connection->getQueryBuilder();
  145. $qb->update('file_locks')
  146. ->set('lock', $qb->func()->subtract('lock', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)))
  147. ->where($qb->expr()->eq('key', $qb->createNamedParameter($path)))
  148. ->andWhere($qb->expr()->gt('lock', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
  149. $qb->executeStatement();
  150. }
  151. }
  152. /** @inheritDoc */
  153. public function changeLock(string $path, int $targetType): void {
  154. $expire = $this->getExpireTime();
  155. if ($targetType === self::LOCK_SHARED) {
  156. $qb = $this->connection->getQueryBuilder();
  157. $result = $qb->update('file_locks')
  158. ->set('lock', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))
  159. ->set('ttl', $qb->createNamedParameter($expire, IQueryBuilder::PARAM_INT))
  160. ->where($qb->expr()->andX(
  161. $qb->expr()->eq('key', $qb->createNamedParameter($path)),
  162. $qb->expr()->eq('lock', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT))
  163. ))->executeStatement();
  164. } else {
  165. // since we only keep one shared lock in the db we need to check if we have more then one shared lock locally manually
  166. if (isset($this->acquiredLocks['shared'][$path]) && $this->acquiredLocks['shared'][$path] > 1) {
  167. throw new LockedException($path);
  168. }
  169. $qb = $this->connection->getQueryBuilder();
  170. $result = $qb->update('file_locks')
  171. ->set('lock', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT))
  172. ->set('ttl', $qb->createNamedParameter($expire, IQueryBuilder::PARAM_INT))
  173. ->where($qb->expr()->andX(
  174. $qb->expr()->eq('key', $qb->createNamedParameter($path)),
  175. $qb->expr()->eq('lock', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))
  176. ))->executeStatement();
  177. }
  178. if ($result !== 1) {
  179. throw new LockedException($path);
  180. }
  181. $this->markChange($path, $targetType);
  182. }
  183. /** @inheritDoc */
  184. public function cleanExpiredLocks(): void {
  185. $expire = $this->timeFactory->getTime();
  186. try {
  187. $qb = $this->connection->getQueryBuilder();
  188. $qb->delete('file_locks')
  189. ->where($qb->expr()->lt('ttl', $qb->createNamedParameter($expire, IQueryBuilder::PARAM_INT)))
  190. ->executeStatement();
  191. } catch (\Exception $e) {
  192. // If the table is missing, the clean up was successful
  193. if ($this->connection->tableExists('file_locks')) {
  194. throw $e;
  195. }
  196. }
  197. }
  198. /** @inheritDoc */
  199. public function releaseAll(): void {
  200. parent::releaseAll();
  201. if (!$this->cacheSharedLocks) {
  202. return;
  203. }
  204. // since we keep shared locks we need to manually clean those
  205. $lockedPaths = array_keys($this->sharedLocks);
  206. $lockedPaths = array_filter($lockedPaths, function ($path) {
  207. return $this->sharedLocks[$path];
  208. });
  209. $chunkedPaths = array_chunk($lockedPaths, 100);
  210. $qb = $this->connection->getQueryBuilder();
  211. $qb->update('file_locks')
  212. ->set('lock', $qb->func()->subtract('lock', $qb->expr()->literal(1)))
  213. ->where($qb->expr()->in('key', $qb->createParameter('chunk')))
  214. ->andWhere($qb->expr()->gt('lock', new Literal(0)));
  215. foreach ($chunkedPaths as $chunk) {
  216. $qb->setParameter('chunk', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
  217. $qb->executeStatement();
  218. }
  219. }
  220. }