Skip to content

Commit 157aaae

Browse files
committed
Discover migrations once and lock the cold schema build
1 parent 55581e3 commit 157aaae

3 files changed

Lines changed: 86 additions & 36 deletions

File tree

src/Database/SchemaCache.php

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
namespace CodeIgniter\PHPStan\Database;
1515

16+
use CodeIgniter\Database\SQLite3\Connection;
1617
use CodeIgniter\PHPStan\Database\Schema\Schema;
18+
use stdClass;
1719

1820
/**
1921
* Builds and caches the project's database schema as a var_export'd PHP file, rebuilding it when
@@ -35,19 +37,16 @@ public function get(?string $namespace = null): Schema
3537
$db = $this->connectionFactory->create($databasePath);
3638

3739
try {
38-
$fingerprint = $this->migrator->fingerprint($db, $namespace);
40+
$migrations = $this->migrator->discover($db, $namespace);
41+
$fingerprint = $this->migrator->fingerprint($migrations);
3942

40-
$cached = is_file($cacheFile) ? include $cacheFile : null;
43+
$cached = $this->load($cacheFile);
4144

42-
if ($cached instanceof Schema && $cached->hash === $fingerprint) {
45+
if ($cached !== null && $cached->hash === $fingerprint) {
4346
return $cached;
4447
}
4548

46-
$this->migrator->migrate($db, $namespace);
47-
$schema = $this->introspector->introspect($db, $fingerprint);
48-
$this->store($cacheFile, $schema);
49-
50-
return $schema;
49+
return $this->build($cacheFile, $db, $migrations, $fingerprint);
5150
} finally {
5251
$db->close();
5352

@@ -57,11 +56,52 @@ public function get(?string $namespace = null): Schema
5756
}
5857
}
5958

59+
/**
60+
* Builds the schema under an exclusive lock, then reads back any matching cache a concurrent worker
61+
* wrote while this one waited for the lock, so only the first worker on a cold cache runs the migrations.
62+
*
63+
* @param list<stdClass> $migrations
64+
*/
65+
private function build(string $cacheFile, Connection $db, array $migrations, string $fingerprint): Schema
66+
{
67+
$lock = fopen(sprintf('%s.lock', $cacheFile), 'cb');
68+
69+
if ($lock !== false) {
70+
flock($lock, LOCK_EX);
71+
}
72+
73+
try {
74+
$cached = $this->load($cacheFile);
75+
76+
if ($cached !== null && $cached->hash === $fingerprint) {
77+
return $cached;
78+
}
79+
80+
$this->migrator->migrate($db, $migrations);
81+
$schema = $this->introspector->introspect($db, $fingerprint);
82+
$this->store($cacheFile, $schema);
83+
84+
return $schema;
85+
} finally {
86+
if ($lock !== false) {
87+
flock($lock, LOCK_UN);
88+
fclose($lock);
89+
}
90+
}
91+
}
92+
93+
private function load(string $cacheFile): ?Schema
94+
{
95+
$cached = is_file($cacheFile) ? include $cacheFile : null;
96+
97+
return $cached instanceof Schema ? $cached : null;
98+
}
99+
60100
private function store(string $cacheFile, Schema $schema): void
61101
{
62-
$temporaryFile = $cacheFile . '.' . uniqid('', true) . '.tmp';
102+
$temporaryFile = sprintf('%s.%s.tmp', $cacheFile, uniqid('', true));
63103

64-
file_put_contents($temporaryFile, '<?php return ' . var_export($schema, true) . ";\n");
104+
file_put_contents($temporaryFile, sprintf("<?php\n\nreturn %s;\n", var_export($schema, true)));
65105
rename($temporaryFile, $cacheFile);
66106
}
67107
}

src/Database/SchemaMigrator.php

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,35 @@
2929
final class SchemaMigrator
3030
{
3131
/**
32-
* Fingerprint of the migration set, computed from the files without running them.
32+
* Discovers the project's migrations without running them, so the caller can fingerprint and apply
33+
* the same set without scanning twice.
34+
*
35+
* @return list<stdClass>
36+
*/
37+
public function discover(Connection $db, ?string $namespace = null): array
38+
{
39+
$runner = new MigrationRunner(config(Migrations::class), $db);
40+
41+
// A null namespace makes the runner scan every registered namespace (the app plus installed
42+
// packages), so a library analyzed on its own and an app's vendor migrations are both found.
43+
$runner->setNamespace($namespace);
44+
45+
return array_values(array_filter(
46+
$runner->findMigrations(),
47+
static fn (mixed $migration): bool => $migration instanceof stdClass,
48+
));
49+
}
50+
51+
/**
52+
* Fingerprint of a discovered migration set, computed from the files without running them.
53+
*
54+
* @param list<stdClass> $migrations
3355
*/
34-
public function fingerprint(Connection $db, ?string $namespace = null): string
56+
public function fingerprint(array $migrations): string
3557
{
3658
$fingerprint = '';
3759

38-
foreach ($this->discoverMigrations($db, $namespace) as $migration) {
60+
foreach ($migrations as $migration) {
3961
$path = $migration->path;
4062
$uid = $migration->uid;
4163

@@ -50,11 +72,14 @@ public function fingerprint(Connection $db, ?string $namespace = null): string
5072
return hash('sha256', $fingerprint);
5173
}
5274

53-
public function migrate(Connection $db, ?string $namespace = null): void
75+
/**
76+
* @param list<stdClass> $migrations
77+
*/
78+
public function migrate(Connection $db, array $migrations): void
5479
{
5580
$forge = Database::forge($db);
5681

57-
foreach ($this->discoverMigrations($db, $namespace) as $migration) {
82+
foreach ($migrations as $migration) {
5883
$path = $migration->path;
5984
$class = $migration->class;
6085

@@ -82,23 +107,6 @@ public function migrate(Connection $db, ?string $namespace = null): void
82107
}
83108
}
84109

85-
/**
86-
* @return list<stdClass>
87-
*/
88-
private function discoverMigrations(Connection $db, ?string $namespace): array
89-
{
90-
$runner = new MigrationRunner(config(Migrations::class), $db);
91-
92-
// A null namespace makes the runner scan every registered namespace (the app plus installed
93-
// packages), so a library analyzed on its own and an app's vendor migrations are both found.
94-
$runner->setNamespace($namespace);
95-
96-
return array_values(array_filter(
97-
$runner->findMigrations(),
98-
static fn (mixed $migration): bool => $migration instanceof stdClass,
99-
));
100-
}
101-
102110
/**
103111
* Reads the migration's `$DBGroup` default without instantiating it, since instantiating a
104112
* pinned migration would open a connection to that group.

tests/Database/SchemaMigratorTest.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ protected function tearDown(): void
5656
public function testRunsMigrationsResilientlyAndReturnsFingerprint(): void
5757
{
5858
$migrator = new SchemaMigrator();
59-
$fingerprint = $migrator->fingerprint($this->db, self::FIXTURE_NAMESPACE);
59+
$migrations = $migrator->discover($this->db, self::FIXTURE_NAMESPACE);
60+
$fingerprint = $migrator->fingerprint($migrations);
6061

6162
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $fingerprint);
6263

63-
$migrator->migrate($this->db, self::FIXTURE_NAMESPACE);
64+
$migrator->migrate($this->db, $migrations);
6465

6566
$schema = (new SchemaIntrospector())->introspect($this->db, $fingerprint);
6667

@@ -75,8 +76,9 @@ public function testRunsMigrationsResilientlyAndReturnsFingerprint(): void
7576
public function testNullNamespaceScansEveryRegisteredNamespace(): void
7677
{
7778
$migrator = new SchemaMigrator();
78-
$fingerprint = $migrator->fingerprint($this->db, null);
79-
$migrator->migrate($this->db, null);
79+
$migrations = $migrator->discover($this->db, null);
80+
$fingerprint = $migrator->fingerprint($migrations);
81+
$migrator->migrate($this->db, $migrations);
8082

8183
$schema = (new SchemaIntrospector())->introspect($this->db, $fingerprint);
8284

0 commit comments

Comments
 (0)