owncloud/core

View on GitHub
lib/private/Lock/DBLockingProvider.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?php
/**
 * @author Individual IT Services <info@individual-it.net>
 * @author Markus Goetz <markus@woboq.com>
 * @author Morris Jobke <hey@morrisjobke.de>
 * @author Robin Appelman <icewind@owncloud.com>
 * @author Roeland Jago Douma <rullzer@owncloud.com>
 *
 * @copyright Copyright (c) 2018, ownCloud GmbH
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License, version 3,
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
 *
 */

namespace OC\Lock;

use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use OC\DB\QueryBuilder\Literal;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\ILogger;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;

/**
 * Locking provider that stores the locks in the database
 */
class DBLockingProvider extends AbstractLockingProvider {
    /**
     * @var \OCP\IDBConnection
     */
    private $connection;

    /**
     * @var \OCP\ILogger
     */
    private $logger;

    /**
     * @var \OCP\AppFramework\Utility\ITimeFactory
     */
    private $timeFactory;

    private $sharedLocks = [];

    /**
     * Check if we have an open shared lock for a path
     *
     * @param string $path
     * @return bool
     */
    protected function isLocallyLocked($path) {
        return isset($this->sharedLocks[$path]) && $this->sharedLocks[$path];
    }

    /**
     * Mark a locally acquired lock
     *
     * @param string $path
     * @param int $type self::LOCK_SHARED or self::LOCK_EXCLUSIVE
     */
    protected function markAcquire($path, $type) {
        parent::markAcquire($path, $type);
        if ($type === self::LOCK_SHARED) {
            $this->sharedLocks[$path] = true;
        }
    }

    /**
     * Change the type of an existing tracked lock
     *
     * @param string $path
     * @param int $targetType self::LOCK_SHARED or self::LOCK_EXCLUSIVE
     */
    protected function markChange($path, $targetType) {
        parent::markChange($path, $targetType);
        if ($targetType === self::LOCK_SHARED) {
            $this->sharedLocks[$path] = true;
        } elseif ($targetType === self::LOCK_EXCLUSIVE) {
            $this->sharedLocks[$path] = false;
        }
    }

    /**
     * @param \OCP\IDBConnection $connection
     * @param \OCP\ILogger $logger
     * @param \OCP\AppFramework\Utility\ITimeFactory $timeFactory
     * @param int $ttl
     */
    public function __construct(IDBConnection $connection, ILogger $logger, ITimeFactory $timeFactory, $ttl = 3600) {
        $this->connection = $connection;
        $this->logger = $logger;
        $this->timeFactory = $timeFactory;
        $this->ttl = $ttl;
    }

    /**
     * Insert a file locking row if it does not exist.
     *
     * @param string $path
     * @param int $lock
     * @return int number of inserted rows
     */

    protected function initLockField($path, $lock = 0) {
        $expire = $this->getExpireTime();

        try {
            $rows = $this->connection->insertIfNotExist(
                '*PREFIX*file_locks',
                ['key' => $path, 'lock' => $lock, 'ttl' => $expire],
                ['key'] // NOTE: see *PREFIX*file_locks/lock_key_index
            );

            if ($rows === 0) {
                // likely to happen when called multiple times
                $this->logger->debug(
                    "Lock {$path}/{$lock} already exists"
                );
            }
            return $rows;
        } catch (UniqueConstraintViolationException $e) {
            // most likely race condition but make sure to log the error in case it is application level error
            $this->logger->error(
                "Attempt to add lock {$path}/{$lock} failed"
            );
            $this->logger->logException($e);
            return 0;
        }
    }

    /**
     * @return int
     */
    protected function getExpireTime() {
        return $this->timeFactory->getTime() + $this->ttl;
    }

    /**
     * @param string $path
     * @param int $type self::LOCK_SHARED or self::LOCK_EXCLUSIVE
     * @return bool
     */
    public function isLocked($path, $type) {
        if ($this->hasAcquiredLock($path, $type)) {
            return true;
        }
        $query = $this->connection->prepare('SELECT `lock` from `*PREFIX*file_locks` WHERE `key` = ?');
        $query->execute([$path]);
        $lockValue = (int)$query->fetchColumn();
        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;
        }
    }

    /**
     * @param string $path
     * @param int $type self::LOCK_SHARED or self::LOCK_EXCLUSIVE
     * @throws \OCP\Lock\LockedException
     */
    public function acquireLock($path, $type) {
        if (\strlen($path) > 64) { // max length in file_locks
            throw new \InvalidArgumentException("Lock key length too long");
        }
        $expire = $this->getExpireTime();
        if ($type === self::LOCK_SHARED) {
            if (!$this->isLocallyLocked($path)) {
                $result = $this->initLockField($path, 1);
                if ($result <= 0) {
                    $result = $this->connection->executeUpdate(
                        'UPDATE `*PREFIX*file_locks` SET `lock` = `lock` + 1, `ttl` = ? WHERE `key` = ? AND `lock` >= 0',
                        [$expire, $path]
                    );
                }
            } 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) {
                $result = $this->connection->executeUpdate(
                    'UPDATE `*PREFIX*file_locks` SET `lock` = -1, `ttl` = ? WHERE `key` = ? AND `lock` = ?',
                    [$expire, $path, $existing]
                );
            }
        }
        if ($result !== 1) {
            throw new LockedException($path);
        }
        $this->markAcquire($path, $type);
    }

    /**
     * @param string $path
     * @param int $type self::LOCK_SHARED or self::LOCK_EXCLUSIVE
     */
    public function releaseLock($path, $type) {
        $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) {
            $this->connection->executeUpdate(
                'UPDATE `*PREFIX*file_locks` SET `lock` = 0 WHERE `key` = ? AND `lock` = -1',
                [$path]
            );
        }
    }

    /**
     * Change the type of an existing lock
     *
     * @param string $path
     * @param int $targetType self::LOCK_SHARED or self::LOCK_EXCLUSIVE
     * @throws \OCP\Lock\LockedException
     */
    public function changeLock($path, $targetType) {
        $expire = $this->getExpireTime();
        if ($targetType === self::LOCK_SHARED) {
            $result = $this->connection->executeUpdate(
                'UPDATE `*PREFIX*file_locks` SET `lock` = 1, `ttl` = ? WHERE `key` = ? AND `lock` = -1',
                [$expire, $path]
            );
        } 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);
            }
            $result = $this->connection->executeUpdate(
                'UPDATE `*PREFIX*file_locks` SET `lock` = -1, `ttl` = ? WHERE `key` = ? AND `lock` = 1',
                [$expire, $path]
            );
        }
        if ($result !== 1) {
            throw new LockedException($path);
        }
        $this->markChange($path, $targetType);
    }

    /**
     * cleanup empty locks
     */
    public function cleanExpiredLocks() {
        $expire = $this->timeFactory->getTime();
        try {
            $this->connection->executeUpdate(
                'DELETE FROM `*PREFIX*file_locks` WHERE `ttl` < ?',
                [$expire]
            );
        } catch (\Exception $e) {
            // If the table is missing, the clean up was successful
            if ($this->connection->tableExists('file_locks')) {
                throw $e;
            }
        }
    }

    /**
     * release all lock acquired by this instance which were marked using the mark* methods
     */
    public function releaseAll() {
        parent::releaseAll();

        // 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);

        foreach ($chunkedPaths as $chunk) {
            $builder = $this->connection->getQueryBuilder();

            $query = $builder->update('file_locks')
                ->set('lock', $builder->createFunction('`lock` -1'))
                ->where($builder->expr()->in('key', $builder->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY)))
                ->andWhere($builder->expr()->gt('lock', new Literal(0)));

            $query->execute();
        }
    }
}