Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add parallel cache support #7131

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 26 additions & 3 deletions doc/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,28 @@ The ``fix`` command
-------------------

The ``fix`` command tries to fix as much coding standards
problems as possible on a given file or files in a given directory and its subdirectories:
problems as possible.


With config file created, you can run command as easy as:

.. code-block:: console

php php-cs-fixer.phar fix

If you do not have config file, you can run following command to fix non-hidden, non-vendor/ PHP files with default ruleset @PSR12:

.. code-block:: console

php php-cs-fixer.phar fix .

With some magic of tools provided by your OS, you can also fix files in parallel:

.. code-block:: console

php php-cs-fixer.phar list-files --config=.php-cs-fixer.dist.php | xargs -n 10 -P 8 php php-cs-fixer.phar fix --config=.php-cs-fixer.dist.php --path-mode intersection -v

You can limit process to given file or files in a given directory and its subdirectories:

.. code-block:: console

Expand Down Expand Up @@ -175,8 +196,10 @@ Caching
The caching mechanism is enabled by default. This will speed up further runs by
fixing only files that were modified since the last run. The tool will fix all
files if the tool version has changed or the list of rules has changed.
Cache is supported only for tool downloaded as phar file or installed via
composer.
The cache is supported only when the tool was downloaded as a phar file or
installed via Composer. The cache is written to the drive progressively, so do
not be afraid of interruption - rerun the command and start where you left.
The cache mechanism also supports executing the command in parallel.

Cache can be disabled via ``--using-cache`` option or config file:

Expand Down
14 changes: 14 additions & 0 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,18 @@ public static function fromJson(string $json): self

return $cache;
}

/**
* @internal
*/
public function backfillHashes(self $oldCache): bool
{
if (!$this->getSignature()->equals($oldCache->getSignature())) {
return false;
}

$this->hashes = array_merge($oldCache->hashes, $this->hashes);

return true;
}
}
166 changes: 119 additions & 47 deletions src/Cache/FileHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,95 +18,167 @@

/**
* @author Andreas Möller <am@localheinz.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class FileHandler implements FileHandlerInterface
{
private string $file;
private \SplFileInfo $fileInfo;

private int $fileMTime = 0;

public function __construct(string $file)
{
$this->file = $file;
$this->fileInfo = new \SplFileInfo($file);
}

public function getFile(): string
{
return $this->file;
return $this->fileInfo->getPathname();
}

public function read(): ?CacheInterface
{
if (!file_exists($this->file)) {
if (!$this->fileInfo->isFile() || !$this->fileInfo->isReadable()) {
return null;
}

$content = file_get_contents($this->file);
$fileObject = $this->fileInfo->openFile('r');

try {
$cache = Cache::fromJson($content);
} catch (\InvalidArgumentException $exception) {
return null;
}
$cache = $this->readFromHandle($fileObject);
$this->fileMTime = $this->getFileCurrentMTime();

unset($fileObject); // explicitly close file handler

return $cache;
}

public function write(CacheInterface $cache): void
{
$content = $cache->toJson();

if (file_exists($this->file)) {
if (is_dir($this->file)) {
throw new IOException(
sprintf('Cannot write cache file "%s" as the location exists as directory.', realpath($this->file)),
0,
null,
$this->file
);
$this->ensureFileIsWriteable();

$fileObject = $this->fileInfo->openFile('r+');

if (method_exists($cache, 'backfillHashes') && $this->fileMTime < $this->getFileCurrentMTime()) {
$resultOfFlock = $fileObject->flock(LOCK_EX);
if (false === $resultOfFlock) {
// Lock failed, OK - we continue without the lock.
// noop
}

if (!is_writable($this->file)) {
throw new IOException(
sprintf('Cannot write to file "%s" as it is not writable.', realpath($this->file)),
0,
null,
$this->file
);
$oldCache = $this->readFromHandle($fileObject);

$fileObject->rewind();

if (null !== $oldCache) {
$cache->backfillHashes($oldCache);
}
} else {
$dir = \dirname($this->file);
}

$resultOfTruncate = $fileObject->ftruncate(0);
if (false === $resultOfTruncate) {
// Truncate failed. OK - we do not save the cache.
return;
}

$resultOfWrite = $fileObject->fwrite($cache->toJson());
if (false === $resultOfWrite) {
// Write failed. OK - we did not save the cache.
return;
}

$resultOfFlush = $fileObject->fflush();
if (false === $resultOfFlush) {
// Flush failed. OK - part of cache can be missing, in case this was last chunk in this pid.
// noop
}

$this->fileMTime = time(); // we could take the fresh `mtime` of file that we just modified with `$this->getFileCurrentMTime()`, but `time()` should be good enough here and reduce IO operation
}

private function getFileCurrentMTime(): int
{
clearstatcache(true, $this->fileInfo->getPathname());

$mtime = $this->fileInfo->getMTime();

if (false === $mtime) {
// cannot check mtime? OK - let's pretend file is old.
$mtime = 0;
}

return $mtime;
}

// Ensure path is created, but ignore if already exists. FYI: ignore EA suggestion in IDE,
// `mkdir()` returns `false` for existing paths, so we can't mix it with `is_dir()` in one condition.
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
private function readFromHandle(\SplFileObject $fileObject): ?CacheInterface
{
try {
$size = $fileObject->getSize();
if (false === $size || 0 === $size) {
return null;
}

if (!is_dir($dir)) {
throw new IOException(
sprintf('Directory of cache file "%s" does not exists and couldn\'t be created.', $this->file),
0,
null,
$this->file
);
$content = $fileObject->fread($size);

if (false === $content) {
return null;
}

@touch($this->file);
@chmod($this->file, 0666);
return Cache::fromJson($content);
} catch (\InvalidArgumentException $exception) {
return null;
}
}

private function ensureFileIsWriteable(): void
{
if ($this->fileInfo->isFile() && $this->fileInfo->isWritable()) {
// all good
return;
}

if ($this->fileInfo->isDir()) {
throw new IOException(
sprintf('Cannot write cache file "%s" as the location exists as directory.', $this->fileInfo->getRealPath()),
0,
null,
$this->fileInfo->getPathname()
);
}

if ($this->fileInfo->isFile() && !$this->fileInfo->isWritable()) {
throw new IOException(
sprintf('Cannot write to file "%s" as it is not writable.', $this->fileInfo->getRealPath()),
0,
null,
$this->fileInfo->getPathname()
);
}

$bytesWritten = @file_put_contents($this->file, $content);
$this->createFile($this->fileInfo->getPathname());
}

private function createFile(string $file): void
{
$dir = \dirname($file);

if (false === $bytesWritten) {
$error = error_get_last();
// Ensure path is created, but ignore if already exists. FYI: ignore EA suggestion in IDE,
// `mkdir()` returns `false` for existing paths, so we can't mix it with `is_dir()` in one condition.
if (!@is_dir($dir)) {
@mkdir($dir, 0777, true);
}

if (!@is_dir($dir)) {
throw new IOException(
sprintf('Failed to write file "%s", "%s".', $this->file, $error['message'] ?? 'no reason available'),
sprintf('Directory of cache file "%s" does not exists and couldn\'t be created.', $file),
0,
null,
$this->file
$file
);
}

@touch($file);
@chmod($file, 0666);
}
}