Skip to content

Commit 780da32

Browse files
authored
Add a file based query cache to leverage OPcache for improved performance
1 parent 204f5d6 commit 780da32

18 files changed

+901
-154
lines changed

CHANGELOG.md

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

1010
## Unreleased
1111

12+
### Added
13+
14+
- Add a file based query cache to leverage OPcache for improved performance https://github.com/nuwave/lighthouse/pull/2713
15+
16+
### Deprecated
17+
18+
- Deprecate command `lighthouse:clear-cache` in favor of `lighthouse:clear-schema-cache` https://github.com/nuwave/lighthouse/pull/2713
19+
1220
## v6.62.3
1321

1422
### Fixed

UPGRADE.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ type Post {
5555
}
5656
```
5757

58+
### Replace `lighthouse:clear-cache` with `lighthouse:clear-schema-cache`
59+
60+
The Artisan command `lighthouse:clear-cache` was renamed to `lighthouse:clear-schema-cache`.
61+
62+
```diff
63+
-php artisan lighthouse:clear-cache
64+
+php artisan lighthouse:clear-schema-cache
65+
```
66+
5867
## v5 to v6
5968

6069
### `messages` on `@rules` and `@rulesForArray`
@@ -342,7 +351,7 @@ abstract class TestCase extends BaseTestCase
342351
### Schema caching v1 removal
343352

344353
Schema caching now uses v2 only. That means, the schema cache will be
345-
written to a php file that OPCache will pick up instead of being written
354+
written to a php file that OPcache will pick up instead of being written
346355
to the configured cache driver. This significantly reduces memory usage.
347356

348357
If you had previously depended on the presence of the schema in your

docs/6/performance/query-caching.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,61 @@
22

33
In order to speed up GraphQL query parsing, the parsed queries can be stored in the Laravel cache.
44

5+
## Configuration
6+
57
Query caching is enabled by default.
6-
You can define cache store and cache duration, see `config/lighthouse.php`.
8+
You can disable it by setting `query_cache.enable` to `false` in `config/lighthouse.php`.
9+
10+
You can control how query caching works through the option `query_cache.mode` in `config/lighthouse.php`.
11+
Make sure to [clear the query cache](#cache-invalidation) when changing the mode.
12+
13+
### Mode `store`
14+
15+
Use an external shared cache through a Laravel cache store like Redis or Memcached.
16+
This is only recommended if your application can not write to the local filesystem.
17+
18+
### Mode `opcache`
19+
20+
Store parsed queries in PHP files on the local filesystem to leverage OPcache.
21+
This is recommended if your application is running on a single server instance with write access to a persistent local filesystem.
22+
23+
### Mode `hybrid`
24+
25+
Leverage OPcache, but use a shared cache store when local files are not found.
26+
This is recommended if your application is running on multiple server instances with write access to a persistent local filesystem.
27+
28+
## Cache invalidation
29+
30+
You may set the option `query_cache.ttl` in `config/lighthouse.php` to remove cache entries automatically after a given number of seconds.
31+
This is only supported when using an external shared cache through a Laravel cache store like Redis or Memcached.
32+
That way, old queries that are potentially unused will be removed after a while.
733

8-
Make sure you flush the query cache when you deploy an upgraded version of the `webonyx/graphql-php` dependency:
34+
When using the modes [`opcache`](#mode-opcache) or [`hybrid`](#mode-hybrid), you need to remove old cached query files manually.
35+
For example, you may run the following command periodically to remove all cached query files older than 24 hours.
936

1037
```shell
11-
php artisan cache:clear
38+
php artisan lighthouse:clear-query-cache --opcache-only --opcache-ttl-hours=24
1239
```
1340

41+
In some scenarios, you may need to clear the query cache completely.
42+
43+
The Artisan command works based on your current configuration for `query_cache` in `config/lighthouse.php`.
44+
45+
- When using the modes [`store`](#mode-store) or [`hybrid`](#mode-hybrid), all entries in the configured cache store will be removed regardless of their age or whether they even belong to Lighthouse.
46+
- When using the modes [`opcache`](#mode-opcache) or [`hybrid`](#mode-hybrid), all cached query files will be removed.
47+
48+
When you plan to change `query_cache.mode`, clear your cache while your current configuration is still in place.
49+
50+
```shell
51+
php artisan lighthouse:clear-query-cache
52+
```
53+
54+
Other reasons to clear the query cache completely include:
55+
56+
- you plan to upgrade the package `webonyx/graphql-php` to a new version that changes the internal representation of parsed queries
57+
- you have stale queries in your cache that have an inappropriate or missing TTL
58+
- you want to free up disk space used by cached query files
59+
1460
## Automated Persisted Queries
1561

1662
Lighthouse supports Automatic Persisted Queries (APQ), compatible with the

docs/master/performance/query-caching.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,61 @@
22

33
In order to speed up GraphQL query parsing, the parsed queries can be stored in the Laravel cache.
44

5+
## Configuration
6+
57
Query caching is enabled by default.
6-
You can define cache store and cache duration, see `config/lighthouse.php`.
8+
You can disable it by setting `query_cache.enable` to `false` in `config/lighthouse.php`.
9+
10+
You can control how query caching works through the option `query_cache.mode` in `config/lighthouse.php`.
11+
Make sure to [clear the query cache](#cache-invalidation) when changing the mode.
12+
13+
### Mode `store`
14+
15+
Use an external shared cache through a Laravel cache store like Redis or Memcached.
16+
This is only recommended if your application can not write to the local filesystem.
17+
18+
### Mode `opcache`
19+
20+
Store parsed queries in PHP files on the local filesystem to leverage OPcache.
21+
This is recommended if your application is running on a single server instance with write access to a persistent local filesystem.
22+
23+
### Mode `hybrid`
24+
25+
Leverage OPcache, but use a shared cache store when local files are not found.
26+
This is recommended if your application is running on multiple server instances with write access to a persistent local filesystem.
27+
28+
## Cache invalidation
29+
30+
You may set the option `query_cache.ttl` in `config/lighthouse.php` to remove cache entries automatically after a given number of seconds.
31+
This is only supported when using an external shared cache through a Laravel cache store like Redis or Memcached.
32+
That way, old queries that are potentially unused will be removed after a while.
733

8-
Make sure you flush the query cache when you deploy an upgraded version of the `webonyx/graphql-php` dependency:
34+
When using the modes [`opcache`](#mode-opcache) or [`hybrid`](#mode-hybrid), you need to remove old cached query files manually.
35+
For example, you may run the following command periodically to remove all cached query files older than 24 hours.
936

1037
```shell
11-
php artisan cache:clear
38+
php artisan lighthouse:clear-query-cache --opcache-only --opcache-ttl-hours=24
1239
```
1340

41+
In some scenarios, you may need to clear the query cache completely.
42+
43+
The Artisan command works based on your current configuration for `query_cache` in `config/lighthouse.php`.
44+
45+
- When using the modes [`store`](#mode-store) or [`hybrid`](#mode-hybrid), all entries in the configured cache store will be removed regardless of their age or whether they even belong to Lighthouse.
46+
- When using the modes [`opcache`](#mode-opcache) or [`hybrid`](#mode-hybrid), all cached query files will be removed.
47+
48+
When you plan to change `query_cache.mode`, clear your cache while your current configuration is still in place.
49+
50+
```shell
51+
php artisan lighthouse:clear-query-cache
52+
```
53+
54+
Other reasons to clear the query cache completely include:
55+
56+
- you plan to upgrade the package `webonyx/graphql-php` to a new version that changes the internal representation of parsed queries
57+
- you have stale queries in your cache that have an inappropriate or missing TTL
58+
- you want to free up disk space used by cached query files
59+
1460
## Automated Persisted Queries
1561

1662
Lighthouse supports Automatic Persisted Queries (APQ), compatible with the

src/Cache/QueryCache.php

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nuwave\Lighthouse\Cache;
4+
5+
use GraphQL\Language\AST\DocumentNode;
6+
use GraphQL\Utils\AST;
7+
use Illuminate\Config\Repository as ConfigRepository;
8+
use Illuminate\Container\Container;
9+
use Illuminate\Contracts\Cache\Factory as CacheFactory;
10+
use Illuminate\Contracts\Cache\Repository as CacheRepository;
11+
use Illuminate\Filesystem\Filesystem;
12+
13+
class QueryCache
14+
{
15+
protected bool $enable;
16+
17+
protected string $mode;
18+
19+
protected string $opcachePath;
20+
21+
protected ?string $store;
22+
23+
protected ?int $ttl;
24+
25+
public function __construct(
26+
protected ConfigRepository $configRepository,
27+
protected Filesystem $filesystem,
28+
) {
29+
$config = $this->configRepository->get('lighthouse.query_cache');
30+
31+
$this->enable = (bool) $config['enable'];
32+
$this->mode = $config['mode'] ?? 'store';
33+
$this->opcachePath = $config['opcache_path'] ?? base_path('bootstrap/cache');
34+
$this->store = $config['store'] ?? null;
35+
$this->ttl = $config['ttl'] ?? null;
36+
}
37+
38+
public function isEnabled(): bool
39+
{
40+
return $this->enable;
41+
}
42+
43+
public function clear(?int $opcacheTTLHours, bool $opcacheOnly): void
44+
{
45+
if (in_array($this->mode, ['store', 'hybrid'])
46+
&& ! $opcacheOnly
47+
) {
48+
$store = $this->makeCacheStore();
49+
$store->clear();
50+
}
51+
52+
if (in_array($this->mode, ['opcache', 'hybrid'])) {
53+
$files = $this->filesystem->glob($this->opcacheFilePath('*'));
54+
55+
if (is_int($opcacheTTLHours)) {
56+
$threshold = now()->subHours($opcacheTTLHours)->timestamp;
57+
$files = array_filter(
58+
$files,
59+
fn (string $file): bool => $this->filesystem->lastModified($file) < $threshold,
60+
);
61+
}
62+
63+
$this->filesystem->delete($files);
64+
}
65+
}
66+
67+
/** @param \Closure(): DocumentNode $parse */
68+
public function fromCacheOrParse(string $hash, \Closure $parse): DocumentNode
69+
{
70+
return match ($this->mode) {
71+
'store' => $this->fromStoreOrParse($hash, $parse),
72+
'opcache' => $this->fromOPcacheOrParse($hash, $parse),
73+
'hybrid' => $this->fromHybridOrParse($hash, $parse),
74+
default => throw new \InvalidArgumentException("Invalid query cache mode: {$this->mode}."),
75+
};
76+
}
77+
78+
/** @param \Closure(): DocumentNode $parse */
79+
protected function fromStoreOrParse(string $hash, \Closure $parse): DocumentNode
80+
{
81+
$store = $this->makeCacheStore();
82+
83+
return $store->remember(key: "lighthouse:query:{$hash}", ttl: $this->ttl, callback: $parse);
84+
}
85+
86+
/** @param \Closure(): DocumentNode $parse */
87+
protected function fromOPcacheOrParse(string $hash, \Closure $parse): DocumentNode
88+
{
89+
$filePath = $this->opcacheFilePath($hash);
90+
91+
if ($this->filesystem->exists($filePath)) {
92+
return $this->requireOPcacheFile($filePath);
93+
}
94+
95+
$query = $parse();
96+
97+
$contents = static::opcacheFileContents($query);
98+
$this->filesystem->put(path: $filePath, contents: $contents, lock: true);
99+
100+
return $query;
101+
}
102+
103+
/** @param \Closure(): DocumentNode $parse */
104+
protected function fromHybridOrParse(string $hash, \Closure $parse): DocumentNode
105+
{
106+
$filePath = $this->opcacheFilePath($hash);
107+
108+
if ($this->filesystem->exists($filePath)) {
109+
return $this->requireOPcacheFile($filePath);
110+
}
111+
112+
$store = $this->makeCacheStore();
113+
114+
$contents = $store->get(key: "lighthouse:query:{$hash}");
115+
if (is_string($contents)) {
116+
$this->filesystem->put(path: $filePath, contents: $contents, lock: true);
117+
118+
return $this->requireOPcacheFile($filePath);
119+
}
120+
121+
$query = $parse();
122+
123+
$contents = static::opcacheFileContents($query);
124+
$store->put(key: "lighthouse:query:{$hash}", value: $contents, ttl: $this->ttl);
125+
$this->filesystem->put(path: $filePath, contents: $contents, lock: true);
126+
127+
return $query;
128+
}
129+
130+
protected function makeCacheStore(): CacheRepository
131+
{
132+
$cacheFactory = Container::getInstance()->make(CacheFactory::class);
133+
134+
return $cacheFactory->store($this->store);
135+
}
136+
137+
public static function opcacheFileContents(DocumentNode $query): string
138+
{
139+
$queryArrayString = var_export(
140+
value: $query->toArray(),
141+
return: true,
142+
);
143+
144+
return "<?php return {$queryArrayString};";
145+
}
146+
147+
protected function requireOPcacheFile(string $filePath): DocumentNode
148+
{
149+
$astArray = require $filePath;
150+
assert(is_array($astArray), 'The cache file is expected to return an array.');
151+
152+
$astInstance = AST::fromArray($astArray);
153+
assert($astInstance instanceof DocumentNode, 'The AST array is expected to convert to a DocumentNode.');
154+
155+
return $astInstance;
156+
}
157+
158+
protected function opcacheFilePath(string $hash): string
159+
{
160+
return "{$this->opcachePath}/lighthouse-query-{$hash}.php";
161+
}
162+
}

src/Console/ClearCacheCommand.php

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,8 @@
22

33
namespace Nuwave\Lighthouse\Console;
44

5-
use Illuminate\Console\Command;
6-
use Nuwave\Lighthouse\Schema\AST\ASTCache;
7-
8-
class ClearCacheCommand extends Command
5+
/** @deprecated in favor of lighthouse:clear-schema-cache */
6+
class ClearCacheCommand extends ClearSchemaCacheCommand
97
{
108
protected $name = 'lighthouse:clear-cache';
11-
12-
protected $description = 'Clear the GraphQL schema cache.';
13-
14-
public function handle(ASTCache $cache): void
15-
{
16-
$cache->clear();
17-
18-
$this->info('GraphQL AST schema cache deleted.');
19-
}
209
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nuwave\Lighthouse\Console;
4+
5+
use Illuminate\Console\Command;
6+
use Nuwave\Lighthouse\Cache\QueryCache;
7+
8+
class ClearQueryCacheCommand extends Command
9+
{
10+
protected $signature = <<<'SIGNATURE'
11+
lighthouse:clear-query-cache
12+
{--opcache-ttl-hours= : Clear only OPcache files older than the given number of hours}
13+
{--opcache-only : Clear only OPcache files, ignoring the cache store}
14+
SIGNATURE;
15+
16+
protected $description = 'Clears the GraphQL query cache.';
17+
18+
public function handle(QueryCache $queryCache): void
19+
{
20+
$opcacheTTLHours = $this->option('opcache-ttl-hours');
21+
if ($opcacheTTLHours !== null && ! is_numeric($opcacheTTLHours)) {
22+
$this->error('The --opcache-ttl-hours option must be an integer value representing hours.');
23+
24+
return;
25+
}
26+
27+
$opcacheOnly = $this->option('opcache-only');
28+
if (! is_bool($opcacheOnly)) { // @phpstan-ignore function.alreadyNarrowedType (necessary in some dependency versions)
29+
$this->error('The --opcache-only option must be a boolean.');
30+
31+
return;
32+
}
33+
34+
$queryCache->clear(
35+
opcacheTTLHours: $opcacheTTLHours !== null
36+
? (int) $opcacheTTLHours
37+
: null,
38+
opcacheOnly: $opcacheOnly,
39+
);
40+
41+
$this->info('GraphQL query cache deleted.');
42+
}
43+
}

0 commit comments

Comments
 (0)