initial version

This commit is contained in:
Robin Appelman 2022-08-03 16:52:45 +02:00
commit 4b90444fa3
9 changed files with 654 additions and 0 deletions

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick\AppInfo;
use OC\Lock\MemcacheLockingProvider;
use OC\Lock\NoopLockingProvider;
use OCA\LockPick\DebugLockingProvider;
use OCA\LockPick\TraceStore;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IRequest;
use OCP\Lock\ILockingProvider;
use Psr\Container\ContainerInterface;
class Application extends App implements IBootstrap {
public function __construct(array $urlParams = []) {
parent::__construct('lockpick', $urlParams);
// we are overly aggressive and rude in how we register out locking provider to ensure it gets picked up early
\OC::$server->registerService(ILockingProvider::class, function (ContainerInterface $c) {
$config = $c->get(IConfig::class);
$ttl = $config->getSystemValue('filelocking.ttl', 3600);
if ($config->getSystemValue('filelocking.enabled', true) or (defined('PHPUNIT_RUN') && PHPUNIT_RUN)) {
/** @var \OC\Memcache\Factory $memcacheFactory */
$memcacheFactory = $c->get(ICacheFactory::class);
$memcache = $memcacheFactory->createLocking('lock');
$inner = new MemcacheLockingProvider($memcache, $ttl);
return new DebugLockingProvider($inner, $c->get(IRequest::class), $c->get(TraceStore::class));
}
return new NoopLockingProvider();
});
}
public function register(IRegistrationContext $context): void {
// noop
}
public function boot(IBootContext $context): void {
// noop
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick;
class BackTraceFormatter {
private function filePrefix(array $backtrace): string {
$files = array_map(function (array $item) {
return $item['file'] ?? '';
}, $backtrace);
if (count($files) < 2) {
return $files[0] ?? '';
}
$i = 0;
while (isset($files[0][$i]) && array_reduce($files, function ($every, $item) use ($files, $i) {
return $every && $item[$i] == $files[0][$i];
}, true)) {
$i++;
}
return substr($files[0], 0, $i);
}
public function format(array $backtrace, string $indent = ""): string {
$prefixLength = strlen($this->filePrefix($backtrace));
$backtrace = array_map(function ($trace) use ($prefixLength) {
return [
'line' => isset($trace['file']) ? (substr($trace['file'], $prefixLength) . ' ' . $trace['line']) : '--',
'call' => isset($trace['class']) ? ($trace['class'] . $trace['type'] . $trace['function']) : $trace['function'],
];
}, $backtrace);
$callLength = max(array_map(function ($item) {
return strlen($item['call']);
}, $backtrace));
$output = "";
foreach ($backtrace as $trace) {
if ($output !== "") {
$output .= "\n";
}
$output .= $indent . str_pad($trace['call'], $callLength) . ' - ' . $trace['line'];
}
return $output;
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick\Command;
use OC\Core\Command\Base;
use OCA\LockPick\TraceStore;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ListCommand extends Base {
private TraceStore $store;
public function __construct(TraceStore $store) {
parent::__construct();
$this->store = $store;
}
protected function configure() {
$this
->setName('lockpick:list')
->setDescription('List stored conflict traces');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$traces = $this->store->all();
foreach ($traces as $id => $request) {
$output->writeln("$id - $request");
}
return 0;
}
}

83
lib/Command/Show.php Normal file
View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick\Command;
use OC\Core\Command\Base;
use OCA\LockPick\BackTraceFormatter;
use OCA\LockPick\TraceStore;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Show extends Base {
private TraceStore $store;
private BackTraceFormatter $traceFormatter;
public function __construct(TraceStore $store, BackTraceFormatter $traceFormatter) {
parent::__construct();
$this->store = $store;
$this->traceFormatter = $traceFormatter;
}
protected function configure() {
$this
->setName('lockpick:show')
->setDescription('Show the latest detected lock conflict')
->addArgument('trace_id', InputArgument::OPTIONAL, "Id of the trace to show, default to the latest trace");
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$id = $input->getArgument('trace_id');
if ($id) {
$traces = $this->store->get((int)$id);
if (!is_array($traces)) {
$output->writeln("Trace not found");
return 1;
}
} else {
$traces = $this->store->last();
if (!is_array($traces)) {
$output->writeln("No conflict stored");
return 1;
}
}
$output->writeln("<info>Conflict detected between " . count($traces) . " locks</info>");
$first = true;
foreach ($traces as $item) {
if (!$first) {
$output->writeln("");
}
$first = false;
$output->writeln($item->getTypeName() . " lock from");
$output->writeln($this->traceFormatter->format($item->getBacktrace(), " "));
}
return 0;
}
}

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick;
use OCP\IRequest;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
class DebugLockingProvider implements ILockingProvider {
private ILockingProvider $inner;
private TraceStore $store;
private IRequest $request;
private array $openTraces = [];
private array $pathMap = [];
public function __construct(ILockingProvider $inner, IRequest $request, TraceStore $store) {
$this->inner = $inner;
$this->request = $request;
$this->store = $store;
}
/**
* Normalize path to the "readable" form if possible
*/
private function getPath(string $path, string $readablePath = null): string {
if ($readablePath) {
$this->pathMap[$path] = $readablePath;
return $readablePath;
} elseif (isset($this->pathMap[$path])) {
return $this->pathMap[$path];
} else {
return $path;
}
}
private function openTrace(string $path, int $type): void {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
$trace = $this->unwindTrace($trace);
if (!isset($this->openTraces[$path])) {
$this->openTraces[$path] = [];
}
$this->openTraces[$path][] = [
'trace' => $trace,
'type' => $type,
];
}
/**
* Remove the bits from the backtrace that are from this debug handling
*/
private function unwindTrace(array $backtrace): array {
while($backtrace[0]['class'] === self::class) {
array_shift($backtrace);
}
return $backtrace;
}
private function closeTrace(string $path, int $type): void {
// todo find a way to better link acquire/release pairs
if (!isset($this->openTraces[$path])) {
return;
}
foreach ($this->openTraces[$path] as $id => $trace) {
if ($trace['type'] === $type) {
unset($this->openTraces[$path][$id]);
break;
}
}
$this->openTraces[$path] = array_values($this->openTraces[$path]);
}
private function logConflict(string $path, int $type): void {
$path = $this->getPath($path);
// conflict comes from other request
// todo store traces in memcache so we can debug cross-requests conflicts
if (!isset($this->openTraces[$path])) {
return;
}
$this->openTrace($path, $type);
$this->store->store($this->request->getId(), $this->openTraces[$path]);
}
public function acquireLock(string $path, int $type, ?string $readablePath = null): void {
try {
$this->inner->acquireLock($path, $type, $readablePath);
$this->openTrace($this->getPath($path, $readablePath), $type);
} catch (LockedException $e) {
$this->logConflict($path, $type);
throw $e;
}
}
public function changeLock(string $path, int $targetType): void {
try {
$this->inner->changeLock($path, $targetType);
$this->closeTrace($this->getPath($path), 3 - $targetType);
$this->openTrace($this->getPath($path), $targetType);
} catch (LockedException $e) {
$this->logConflict($path, $targetType);
throw $e;
}
}
public function releaseLock(string $path, int $type): void {
$this->inner->releaseLock($path, $type);
$this->closeTrace($this->getPath($path), $type);
}
public function releaseAll(): void {
$this->inner->releaseAll();
$this->openTraces = [];
}
public function isLocked(string $path, int $type): bool {
return $this->inner->isLocked($path, $type);
}
}

48
lib/LockTrace.php Normal file
View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick;
use OCP\Lock\ILockingProvider;
class LockTrace {
private int $type;
private array $backtrace;
public function __construct(int $type, array $backtrace) {
$this->type = $type;
$this->backtrace = $backtrace;
}
public function getType(): int {
return $this->type;
}
public function getTypeName(): string {
return $this->getType() === ILockingProvider::LOCK_SHARED ? 'shared' : 'exclusive';
}
public function getBacktrace(): array {
return $this->backtrace;
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Your name <your@email.com>
*
* @author Your name <your@email.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick\Migration;
use Closure;
use Doctrine\DBAL\Types\Types;
use OC\DB\SchemaWrapper;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version1Date20220803141943 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var SchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable("lockpick_traces")) {
$table = $schema->createTable("lockpick_traces");
$table->addColumn('trace_id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
$table->addColumn('request_id', 'string', [
'notnull' => true,
'length' => 255,
]);
$table->addColumn('trace', 'text', [
'notnull' => true,
]);
$table->setPrimaryKey(['trace_id']);
$table->addIndex(['request_id']);
}
return $schema;
}
}

104
lib/TraceStore.php Normal file
View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\LockPick;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
class TraceStore {
private IDBConnection $connection;
public function __construct(IDBConnection $connection) {
$this->connection = $connection;
}
public function store(string $request, array $trace): void {
$query = $this->connection->getQueryBuilder();
$query->insert('lockpick_traces')
->values([
'request_id' => $query->createNamedParameter($request),
'trace' => $query->createNamedParameter(json_encode($trace))
]);
$query->executeStatement();
}
/**
* @return LockTrace[]|null
* @throws \OCP\DB\Exception
*/
public function last(): ?array {
$query = $this->connection->getQueryBuilder();
$query->select('trace')
->from('lockpick_traces')
->orderBy('trace_id', 'DESC')
->setMaxResults(1);
$trace = $query->executeQuery()->fetchOne();
if ($trace) {
$raw = json_decode($trace, true);
return array_map(function(array $item) {
return new LockTrace($item['type'], $item['trace']);
}, $raw);
} else {
return null;
}
}
/**
* @return array<int, string>
*/
public function all(): array {
$query = $this->connection->getQueryBuilder();
$query->select('trace_id', 'request_id')
->from('lockpick_traces')
->orderBy('trace_id', 'ASC');
$rows = $query->executeQuery()->fetchAll();
$keys = array_map(function(array $row) {
return $row['trace_id'];
}, $rows);
$values = array_map(function(array $row) {
return $row['request_id'];
}, $rows);
return array_combine($keys, $values);
}
/**
* @return LockTrace[]|null
* @throws \OCP\DB\Exception
*/
public function get(int $id): ?array {
$query = $this->connection->getQueryBuilder();
$query->select('trace')
->from('lockpick_traces')
->where($query->expr()->eq('trace_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
$trace = $query->executeQuery()->fetchOne();
if ($trace) {
$raw = json_decode($trace, true);
return array_map(function(array $item) {
return new LockTrace($item['type'], $item['trace']);
}, $raw);
} else {
return null;
}
}
}