Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/master/performance/schema-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ php artisan lighthouse:cache
The structure of the serialized schema can change between Lighthouse releases.
In order to prevent errors, use a deployment method that atomically updates both the cache file and the dependencies, e.g. K8s.

## Long-running processes

When running in a long-running process such as [Laravel Octane](https://laravel.com/docs/octane),
Lighthouse keeps the contents of the schema cache file in memory and reuses them for subsequent requests
until the file changes.
Enable [OPcache](server-configuration.md#opcache) for the worker processes as well - set `opcache.enable_cli=1` when using Swoole or RoadRunner -
so the cached schema is stored in shared memory instead of each worker's own memory.

## Development

In order to speed up responses during development, change this setting to be always on:
Expand Down
77 changes: 75 additions & 2 deletions src/Schema/AST/ASTCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
*/
class ASTCache
{
/**
* Schema cache file contents, kept for the lifetime of the PHP process.
*
* Long-running workers such as Laravel Octane resolve a fresh ASTBuilder for each request.
* Requiring the cache file each time would compile it and allocate the whole array again
* when OPcache is not enabled for the worker, see https://github.com/nuwave/lighthouse/issues/2436.
*
* @var array<string, array{fingerprint: string, astArray: array<mixed>}>
*/
protected static array $loaded = [];

protected bool $enable;

protected string $path;
Expand Down Expand Up @@ -49,18 +60,25 @@ public function set(DocumentAST $documentAST): void
$contents = /** @lang PHP */ "<?php return {$variable};";

Utils::atomicPut(filesystem: $this->filesystem, path: $this->path(), contents: $contents);
unset(static::$loaded[$this->path()]);
}

public function clear(): void
{
$this->filesystem->delete($this->path());
unset(static::$loaded[$this->path()]);
}

/** Forget all schema cache file contents kept in memory, the files on disk are not affected. */
public static function forgetLoaded(): void
{
static::$loaded = [];
}

/** @param callable(): DocumentAST $build */
public function fromCacheOrBuild(callable $build): DocumentAST
{
$path = $this->path();
$astArray = Utils::requireArrayOrForget($this->filesystem, $path);
$astArray = $this->load($this->path());
if ($astArray !== null) {
/** @var SerializableDocumentAST $astArray */

Expand All @@ -72,4 +90,59 @@ public function fromCacheOrBuild(callable $build): DocumentAST

return $documentAST;
}

/**
* Load the cache file contents, reusing them from memory while the file is unchanged.
*
* @return array<mixed>|null
*/
protected function load(string $path): ?array
{
$fingerprint = $this->fingerprint($path);
if ($fingerprint === null) {
unset(static::$loaded[$path]);

return null;
}

$loaded = static::$loaded[$path] ?? null;
if ($loaded !== null && $loaded['fingerprint'] === $fingerprint) {
return $loaded['astArray'];
}

// OPcache may still hold a previous version of the file within opcache.revalidate_freq,
// which would then be kept in memory under the fingerprint of the new file
if (function_exists('opcache_invalidate')) {
opcache_invalidate($path, force: $loaded !== null);
}

$astArray = Utils::requireArrayOrForget($this->filesystem, $path);
if ($astArray === null) {
unset(static::$loaded[$path]);

return null;
}

static::$loaded[$path] = [
'fingerprint' => $fingerprint,
'astArray' => $astArray,
];

return $astArray;
}

/** Identifies a version of the cache file without reading it, null if it does not exist. */
protected function fingerprint(string $path): ?string
{
clearstatcache(true, $path);

// Suppress the warning when the file does not exist, null is returned for that case
$stat = @stat($path);
if ($stat === false) {
return null;
}

// Files are replaced atomically by moving a new file into place, which changes the inode
return "{$stat['ino']}-{$stat['mtime']}-{$stat['size']}";
}
}
116 changes: 116 additions & 0 deletions tests/Integration/SchemaCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Filesystem\Filesystem;
use Nuwave\Lighthouse\Schema\AST\ASTBuilder;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Support\Utils;
use Tests\TestCase;
use Tests\TestsSchemaCache;
use Tests\TestsSerialization;
use Tests\Utils\Models\Comment;

use function Safe\ini_get;
use function Safe\ini_set;

final class SchemaCacheTest extends TestCase
{
use TestsSerialization;
Expand Down Expand Up @@ -99,6 +104,117 @@ public function testRebuildsInvalidFile(): void
$this->assertIsArray(require $path);
}

public function testReusesLoadedCacheFileWhileUnchanged(): void
{
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
type Query {
foo: Int
}
GRAPHQL;
$this->cacheSchema();

$filesystem = new class extends Filesystem {
public int $existsCalls = 0;

public function exists($path): bool
{
++$this->existsCalls;

return parent::exists($path);
}
};
$this->app->instance(Filesystem::class, $filesystem);

// Simulates multiple requests in a long-running process such as Laravel Octane
$hash = $this->freshDocumentAST()->hash;
$this->assertSame($hash, $this->freshDocumentAST()->hash);
$this->assertSame($hash, $this->freshDocumentAST()->hash);

$this->assertSame(1, $filesystem->existsCalls, 'Expected the cache file to be required only once.');
}

public function testReloadsCacheFileWhenItChanges(): void
{
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
type Query {
foo: Int
}
GRAPHQL;
$this->cacheSchema();
$this->assertArrayNotHasKey('Bar', $this->freshDocumentAST()->types);

// Simulates running `php artisan lighthouse:cache` in another process
$changed = DocumentAST::fromSource(/** @lang GraphQL */ <<<'GRAPHQL'
type Query {
foo: Int
}

type Bar {
baz: Int
}
GRAPHQL);
$variable = var_export($changed->toArray(), true);
Utils::atomicPut(
filesystem: $this->app->make(Filesystem::class),
path: $this->schemaCachePath(),
contents: "<?php return {$variable};",
);

$this->assertArrayHasKey('Bar', $this->freshDocumentAST()->types);
}

public function testReloadsCacheFileWhenItChangesWithOPcache(): void
{
if (! extension_loaded('Zend OPcache') || ! filter_var(ini_get('opcache.enable_cli'), FILTER_VALIDATE_BOOL)) {
$this->markTestSkipped('Requires OPcache, e.g. run with -d opcache.enable_cli=1.');
}

// Make OPcache serve the compiled file without checking for changes on disk
$revalidateFreq = ini_set('opcache.revalidate_freq', '3600');
$fileUpdateProtection = ini_set('opcache.file_update_protection', '0');

try {
$this->testReloadsCacheFileWhenItChanges();
} finally {
ini_set('opcache.revalidate_freq', $revalidateFreq);
ini_set('opcache.file_update_protection', $fileUpdateProtection);
}
}

public function testRebuildsWhenCacheFileIsDeleted(): void
{
$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
type Query {
foo: Int
}
GRAPHQL;
$this->cacheSchema();
$this->freshDocumentAST();

$filesystem = $this->app->make(Filesystem::class);
$filesystem->delete($this->schemaCachePath());

$this->schema = /** @lang GraphQL */ <<<'GRAPHQL'
type Query {
foo: Int
}

type Bar {
baz: Int
}
GRAPHQL;

$this->assertArrayHasKey('Bar', $this->freshDocumentAST()->types);
$this->assertFileExists($this->schemaCachePath());
}

private function freshDocumentAST(): DocumentAST
{
$this->app->forgetInstance(ASTBuilder::class);

return $this->app->make(ASTBuilder::class)->documentAST();
}

private function cacheSchema(): void
{
$astBuilder = $this->app->make(ASTBuilder::class);
Expand Down
3 changes: 3 additions & 0 deletions tests/TestsSchemaCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Filesystem\Filesystem;
use Nuwave\Lighthouse\Schema\AST\ASTCache;

trait TestsSchemaCache
{
Expand All @@ -23,5 +24,7 @@ protected function tearDownSchemaCache(): void
{
$filesystem = $this->app->make(Filesystem::class);
$filesystem->delete($this->schemaCachePath());

ASTCache::forgetLoaded();
}
}