Skip to content

Commit 2de0a54

Browse files
authored
Use atomic file writes for query cache (#2716)
1 parent e89a2d2 commit 2de0a54

40 files changed

Lines changed: 158 additions & 110 deletions

.github/workflows/format.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ jobs:
1414

1515
- uses: shivammathur/setup-php@v2
1616
with:
17+
php-version: 8.3
1718
coverage: none
1819
extensions: mbstring
19-
php-version: 8.3
2020

2121
- run: composer install --no-interaction --no-progress --no-suggest
2222

@@ -51,9 +51,9 @@ jobs:
5151

5252
- uses: shivammathur/setup-php@v2
5353
with:
54+
php-version: 8.3
5455
coverage: none
5556
extensions: mbstring
56-
php-version: 8.3
5757

5858
- run: composer install --no-interaction --no-progress --no-suggest
5959

.github/workflows/validate.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ jobs:
5555

5656
- uses: shivammathur/setup-php@v2
5757
with:
58+
php-version: ${{ matrix.php-version }}
5859
coverage: none
5960
extensions: ${{ env.REQUIRED_PHP_EXTENSIONS }}
60-
php-version: ${{ matrix.php-version }}
6161

6262
- name: "Cache composer dependencies"
6363
uses: actions/cache@v3
@@ -140,9 +140,10 @@ jobs:
140140

141141
- uses: shivammathur/setup-php@v2
142142
with:
143+
php-version: ${{ matrix.php-version }}
143144
coverage: none
144145
extensions: ${{ env.REQUIRED_PHP_EXTENSIONS }}
145-
php-version: ${{ matrix.php-version }}
146+
ini-values: zend.assertions=1
146147

147148
- name: "Cache composer dependencies"
148149
uses: actions/cache@v3
@@ -196,9 +197,10 @@ jobs:
196197

197198
- uses: shivammathur/setup-php@v2
198199
with:
200+
php-version: ${{ matrix.php-version }}
199201
coverage: pcov
200202
extensions: ${{ env.REQUIRED_PHP_EXTENSIONS }}
201-
php-version: ${{ matrix.php-version }}
203+
ini-values: zend.assertions=1
202204

203205
- name: "Cache composer dependencies"
204206
uses: actions/cache@v3
@@ -228,8 +230,9 @@ jobs:
228230

229231
- uses: shivammathur/setup-php@v2
230232
with:
231-
extensions: ${{ env.REQUIRED_PHP_EXTENSIONS }}
232233
php-version: ${{ matrix.php-version }}
234+
coverage: none
235+
extensions: ${{ env.REQUIRED_PHP_EXTENSIONS }}
233236

234237
- name: "Cache composer dependencies"
235238
uses: actions/cache@v3

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ You can find and compare releases at the [GitHub release page](https://github.co
99

1010
## Unreleased
1111

12+
## v6.63.1
13+
14+
### Fixed
15+
16+
- Use atomic file writes for query cache https://github.com/nuwave/lighthouse/pull/2716
17+
1218
## v6.63.0
1319

1420
### Added

src/Cache/CacheDirective.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,13 @@ public function handleField(FieldValue $fieldValue): void
6767
: $this->cacheRepository;
6868

6969
$cacheKey = $this->cacheKeyAndTags->key(
70-
$context->user(),
71-
$isPrivate,
72-
$parentName,
73-
$rootID,
74-
$fieldName,
75-
$args,
76-
$path,
70+
user: $context->user(),
71+
isPrivate: $isPrivate,
72+
parentName: $parentName,
73+
id: $rootID,
74+
fieldName: $fieldName,
75+
args: $args,
76+
path: $path,
7777
);
7878

7979
// We found a matching value in the cache, so we can return early without actually running the query.

src/Cache/QueryCache.php

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Contracts\Cache\Factory as CacheFactory;
1010
use Illuminate\Contracts\Cache\Repository as CacheRepository;
1111
use Illuminate\Filesystem\Filesystem;
12+
use Nuwave\Lighthouse\Support\Utils;
1213

1314
class QueryCache
1415
{
@@ -30,7 +31,7 @@ public function __construct(
3031

3132
$this->enable = (bool) $config['enable'];
3233
$this->mode = $config['mode'] ?? 'store';
33-
$this->opcachePath = $config['opcache_path'] ?? base_path('bootstrap/cache');
34+
$this->opcachePath = rtrim($config['opcache_path'] ?? base_path('bootstrap/cache'), '/');
3435
$this->store = $config['store'] ?? null;
3536
$this->ttl = $config['ttl'] ?? null;
3637
}
@@ -86,43 +87,43 @@ protected function fromStoreOrParse(string $hash, \Closure $parse): DocumentNode
8687
/** @param \Closure(): DocumentNode $parse */
8788
protected function fromOPcacheOrParse(string $hash, \Closure $parse): DocumentNode
8889
{
89-
$filePath = $this->opcacheFilePath($hash);
90+
$path = $this->opcacheFilePath($hash);
9091

91-
if ($this->filesystem->exists($filePath)) {
92-
return $this->requireOPcacheFile($filePath);
92+
if ($this->filesystem->exists($path)) {
93+
return $this->requireOPcacheFile($path);
9394
}
9495

9596
$query = $parse();
9697

9798
$contents = static::opcacheFileContents($query);
98-
$this->filesystem->put(path: $filePath, contents: $contents, lock: true);
99+
Utils::atomicPut(filesystem: $this->filesystem, path: $path, contents: $contents);
99100

100101
return $query;
101102
}
102103

103104
/** @param \Closure(): DocumentNode $parse */
104105
protected function fromHybridOrParse(string $hash, \Closure $parse): DocumentNode
105106
{
106-
$filePath = $this->opcacheFilePath($hash);
107+
$path = $this->opcacheFilePath($hash);
107108

108-
if ($this->filesystem->exists($filePath)) {
109-
return $this->requireOPcacheFile($filePath);
109+
if ($this->filesystem->exists($path)) {
110+
return $this->requireOPcacheFile($path);
110111
}
111112

112113
$store = $this->makeCacheStore();
113114

114115
$contents = $store->get(key: "lighthouse:query:{$hash}");
115116
if (is_string($contents)) {
116-
$this->filesystem->put(path: $filePath, contents: $contents, lock: true);
117+
Utils::atomicPut(filesystem: $this->filesystem, path: $path, contents: $contents);
117118

118-
return $this->requireOPcacheFile($filePath);
119+
return $this->requireOPcacheFile($path);
119120
}
120121

121122
$query = $parse();
122123

123124
$contents = static::opcacheFileContents($query);
124125
$store->put(key: "lighthouse:query:{$hash}", value: $contents, ttl: $this->ttl);
125-
$this->filesystem->put(path: $filePath, contents: $contents, lock: true);
126+
Utils::atomicPut(filesystem: $this->filesystem, path: $path, contents: $contents);
126127

127128
return $query;
128129
}
@@ -144,10 +145,10 @@ public static function opcacheFileContents(DocumentNode $query): string
144145
return "<?php return {$queryArrayString};";
145146
}
146147

147-
protected function requireOPcacheFile(string $filePath): DocumentNode
148+
protected function requireOPcacheFile(string $path): DocumentNode
148149
{
149-
$astArray = require $filePath;
150-
assert(is_array($astArray), 'The cache file is expected to return an array.');
150+
$astArray = require $path;
151+
assert(is_array($astArray), "The query cache file at {$path} is expected to return an array.");
151152

152153
$astInstance = AST::fromArray($astArray);
153154
assert($astInstance instanceof DocumentNode, 'The AST array is expected to convert to a DocumentNode.');

src/Console/PrintSchemaCommand.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ public function handle(ASTCache $cache, FilesystemManager $filesystemManager, Sc
8484
return;
8585
}
8686

87-
$filesystemManager->disk($disk)->put($filename, $schemaString);
87+
$filesystem = $filesystemManager->disk($disk);
88+
$filesystem->put(path: $filename, contents: $schemaString);
8889
$this->info("Wrote schema to disk ({$disk}) as {$filename}.");
8990
} else {
9091
$this->info($schemaString);

src/Exceptions/InvalidSchemaCacheContentsException.php

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/GraphQL.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,15 +319,15 @@ public function loadPersistedQuery(string $sha256hash): DocumentNode
319319
|| ! $queryCacheConfig['enable']
320320
) {
321321
// https://github.com/apollographql/apollo-server/blob/37a5c862261806817a1d71852c4e1d9cdb59eab2/packages/apollo-server-errors/src/index.ts#L240-L248
322-
throw new Error('PersistedQueryNotSupported', null, null, [], null, null, ['code' => 'PERSISTED_QUERY_NOT_SUPPORTED']);
322+
throw new Error(message: 'PersistedQueryNotSupported', extensions: ['code' => 'PERSISTED_QUERY_NOT_SUPPORTED']);
323323
}
324324

325325
$cacheFactory = Container::getInstance()->make(CacheFactory::class);
326326
$store = $cacheFactory->store($queryCacheConfig['store']);
327327

328328
return $store->get("lighthouse:query:{$sha256hash}")
329329
// https://github.com/apollographql/apollo-server/blob/37a5c862261806817a1d71852c4e1d9cdb59eab2/packages/apollo-server-errors/src/index.ts#L230-L239
330-
?? throw new Error('PersistedQueryNotFound', null, null, [], null, null, ['code' => 'PERSISTED_QUERY_NOT_FOUND']);
330+
?? throw new Error(message: 'PersistedQueryNotFound', extensions: ['code' => 'PERSISTED_QUERY_NOT_FOUND']);
331331
}
332332

333333
/** @return ErrorsHandler */

src/Schema/AST/ASTCache.php

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
use Illuminate\Contracts\Config\Repository as ConfigRepository;
66
use Illuminate\Filesystem\Filesystem;
7-
use Nuwave\Lighthouse\Exceptions\InvalidSchemaCacheContentsException;
7+
use Nuwave\Lighthouse\Support\Utils;
88

99
/**
1010
* @phpstan-type CacheConfig array{
@@ -48,13 +48,7 @@ public function set(DocumentAST $documentAST): void
4848
);
4949
$contents = /** @lang PHP */ "<?php return {$variable};";
5050

51-
// Since the schema cache can be very large, we write it to a temporary file first.
52-
// This avoids issues with the filesystem not being able to write large files atomically.
53-
// Then, we move the temporary file to the final location which is an atomic operation.
54-
$path = $this->path();
55-
$partialPath = "{$path}.partial";
56-
$this->filesystem->put(path: $partialPath, contents: $contents, lock: true);
57-
$this->filesystem->move(path: $partialPath, target: $path);
51+
Utils::atomicPut(filesystem: $this->filesystem, path: $this->path(), contents: $contents);
5852
}
5953

6054
public function clear(): void
@@ -67,14 +61,12 @@ public function fromCacheOrBuild(callable $build): DocumentAST
6761
{
6862
$path = $this->path();
6963
if ($this->filesystem->exists($path)) {
70-
$ast = require $path;
71-
if (! is_array($ast)) {
72-
throw new InvalidSchemaCacheContentsException($path, $ast);
73-
}
64+
$astArray = require $path;
65+
assert(is_array($astArray), "The schema cache file at {$path} is expected to return an array.");
7466

75-
/** @var SerializableDocumentAST $ast */
67+
/** @var SerializableDocumentAST $astArray */
7668

77-
return DocumentAST::fromArray($ast);
69+
return DocumentAST::fromArray($astArray);
7870
}
7971

8072
$documentAST = $build();

src/Support/Utils.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Nuwave\Lighthouse\Support;
44

55
use Illuminate\Container\Container;
6+
use Illuminate\Filesystem\Filesystem;
67
use Illuminate\Support\Str;
78
use Nuwave\Lighthouse\Exceptions\DefinitionException;
89

@@ -200,4 +201,22 @@ public static function toEnumValueName(string $name): string
200201
? $name
201202
: "_{$name}";
202203
}
204+
205+
/**
206+
* Write the contents to a file atomically.
207+
*
208+
* This is done by writing to a temporary file first and then renaming it.
209+
*
210+
* It circumvents the following issues:
211+
* - large files not being written completely before being read
212+
* - partial writes from other processes while we expect to read what we wrote
213+
*/
214+
public static function atomicPut(Filesystem $filesystem, string $path, string $contents): void
215+
{
216+
$randomSuffix = bin2hex(random_bytes(6));
217+
$tempPath = "{$path}.{$randomSuffix}";
218+
219+
$filesystem->put(path: $tempPath, contents: $contents);
220+
$filesystem->move(path: $tempPath, target: $path);
221+
}
203222
}

0 commit comments

Comments
 (0)