Skip to content

Commit 316979e

Browse files
committed
optimize cache planning and make migration flush fail open
1 parent 3ba4b5b commit 316979e

14 files changed

Lines changed: 269 additions & 49 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ NormCache bypasses reads when correctness cannot be established, including:
201201

202202
Canonical storage requires a supported single-column integer or string primary key. Queries can still use `result` storage when canonical routing is unavailable.
203203

204-
Writes performed through raw SQL or a connection not installed by NormCache are invisible until `invalidate()` or `flushAll()` is called. After changing connection database/schema metadata at runtime, call `NormCache::clearSchemaMetadata()` for that connection.
204+
Writes performed through raw SQL or a connection not installed by NormCache are invisible until `invalidate()` or `flushAll()` is called. After changing a connection's database, schema, or database objects at runtime, call `NormCache::clearSchemaMetadata()` for that connection.
205205

206206
### Consistency & Failure Modes
207207

src/Cache/Engine.php

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,22 @@ public function select(
108108
}
109109

110110
$dependencies = $analysis->tables;
111-
$primaryKey = $this->primaryKeys->resolve($query, $connection, $table);
111+
$forceQueryGroup = $analysis->opaque && $directRoot === null;
112+
$primaryKey = $this->planner->requiresPrimaryKey(
113+
$query,
114+
$table,
115+
$dependencies,
116+
$forceQueryGroup,
117+
$operation,
118+
)
119+
? $this->primaryKeys->resolve($query, $connection, $table)
120+
: null;
112121
$plan = $this->planner->plan(
113122
$query,
114123
$table,
115124
$primaryKey,
116125
$dependencies,
117-
$analysis->opaque && $directRoot === null,
126+
$forceQueryGroup,
118127
$operation,
119128
);
120129
$namespace = $this->identity->namespace($query->configuredTag());
@@ -133,6 +142,8 @@ public function select(
133142
$connection,
134143
$dependencyHashes,
135144
$namespace,
145+
$sql,
146+
$bindings,
136147
);
137148
} else {
138149
$queryHash = $this->identity->hash(
@@ -157,6 +168,8 @@ public function select(
157168
$connection,
158169
$dependencyHashes,
159170
$namespace,
171+
$sql,
172+
$bindings,
160173
);
161174
}
162175
} catch (\InvalidArgumentException) {
@@ -1347,7 +1360,21 @@ private function canonicalQueryHash(
13471360
Connection $connection,
13481361
array $dependencyHashes,
13491362
string $namespace,
1363+
string $sql,
1364+
array $bindings,
13501365
): string {
1366+
if ($query->columns === null || $query->columns === ['*']) {
1367+
return $this->identity->hash(
1368+
route: QueryPlan::CANONICAL,
1369+
rootHash: $plan->root->hash,
1370+
dependencyHashes: $dependencyHashes,
1371+
sql: $sql,
1372+
bindings: $connection->prepareBindings($bindings),
1373+
namespace: $namespace,
1374+
operation: 'select',
1375+
);
1376+
}
1377+
13511378
$canonical = $query->cloneWithoutBindings(['select']);
13521379
$canonical->columns = ['*'];
13531380

src/CacheManager.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public function flushAll(): bool
103103
{
104104
$this->runtime->forgetEpoch();
105105

106-
return $this->increment($this->keys->epoch());
106+
return $this->increment($this->keys->epoch(), force: true);
107107
}
108108

109109
public function disableCache(): bool
@@ -167,9 +167,9 @@ public function clearSchemaMetadata(?string $connection = null): void
167167
$this->primaryKeys->clear($connection);
168168
}
169169

170-
private function increment(string $key): bool
170+
private function increment(string $key, bool $force = false): bool
171171
{
172-
if (!$this->config->enabled) {
172+
if (!$force && !$this->config->enabled) {
173173
return false;
174174
}
175175

src/CacheServiceProvider.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,12 @@ public function boot(): void
106106
});
107107
Event::listen(MigrationsEnded::class, function (): void {
108108
$cache = $this->app->make(CacheManager::class);
109-
$runtime = $this->app->make(RuntimeState::class);
110109
$cache->clearSchemaMetadata();
111-
if ($runtime->available() && (bool) config('normcache.enabled', true) && !$cache->flushAll()) {
112-
throw new \RuntimeException('NormCache failed to flush cache epoch after migrations completed.');
110+
111+
if (!$cache->flushAll()) {
112+
$this->app->make(LoggerInterface::class)->warning(
113+
'NormCache could not advance its epoch after migrations completed. Run normcache:flush before enabling cache traffic.',
114+
);
113115
}
114116
});
115117

src/Planning/QueryPlanner.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,29 @@
1010

1111
final class QueryPlanner
1212
{
13+
/** @param list<TableIdentity> $dependencies */
14+
public function requiresPrimaryKey(
15+
QueryBuilder $query,
16+
TableIdentity $root,
17+
array $dependencies,
18+
bool $forceQueryGroup = false,
19+
string $operation = 'select',
20+
): bool {
21+
$dependencies = $this->uniqueDependencies($dependencies);
22+
23+
if (
24+
$forceQueryGroup
25+
|| $query->joins !== null && $query->joins !== []
26+
|| $this->hasCrossTableUnion($root, $dependencies, $query)
27+
|| !$this->canUseRowShape($query, $operation)
28+
) {
29+
return false;
30+
}
31+
32+
return $this->isWildcard($query, $root)
33+
|| $this->plainColumns($query, $root) !== null;
34+
}
35+
1336
/** @param list<TableIdentity> $dependencies */
1437
public function plan(
1538
QueryBuilder $query,
@@ -29,9 +52,8 @@ public function plan(
2952
return new QueryPlan(QueryPlan::QUERY_GROUP, $root, $dependencies, $primaryKey);
3053
}
3154

32-
$canUseRowShape = $operation === 'select'
33-
&& $primaryKey !== null
34-
&& $this->isSingleRowShape($query);
55+
$canUseRowShape = $primaryKey !== null
56+
&& $this->canUseRowShape($query, $operation);
3557

3658
if (
3759
$canUseRowShape
@@ -119,6 +141,11 @@ private function isSingleRowShape(QueryBuilder $query): bool
119141
&& empty($query->unions);
120142
}
121143

144+
private function canUseRowShape(QueryBuilder $query, string $operation): bool
145+
{
146+
return $operation === 'select' && $this->isSingleRowShape($query);
147+
}
148+
122149
/** @param list<TableIdentity> $dependencies
123150
* @return list<TableIdentity>
124151
*/

src/Planning/TableIdentityResolver.php

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ final class TableIdentityResolver
1313
/** @var array<string, ?TableIdentity> */
1414
private array $resolvedIdentities = [];
1515

16+
/** @var array<string, array<string, true>> */
17+
private array $viewNames = [];
18+
1619
public function clear(?string $connection = null): void
1720
{
1821
if ($connection === null) {
1922
$this->effectiveSchemas = [];
2023
$this->resolvedIdentities = [];
24+
$this->viewNames = [];
2125

2226
return;
2327
}
@@ -31,6 +35,11 @@ public function clear(?string $connection = null): void
3135
static fn(?TableIdentity $id, string $key): bool => !str_starts_with($key, $connection . ':'),
3236
ARRAY_FILTER_USE_BOTH,
3337
);
38+
$this->viewNames = array_filter(
39+
$this->viewNames,
40+
static fn(array $views, string $key): bool => !str_starts_with($key, $connection . ':'),
41+
ARRAY_FILTER_USE_BOTH,
42+
);
3443
}
3544

3645
public function resolve(Connection $connection, mixed $from): ?TableIdentity
@@ -187,34 +196,34 @@ private function effectiveSchema(Connection $connection): ?string
187196

188197
private function isView(Connection $connection, string $schema, string $table): ?bool
189198
{
199+
$key = $this->mutableConnectionKey($connection) . ':views:' . $schema;
200+
201+
if (isset($this->viewNames[$key])) {
202+
return isset($this->viewNames[$key][strtolower((string) $connection->getTablePrefix() . $this->unqualifiedTable($table))]);
203+
}
204+
190205
try {
191-
$view = $this->unqualifiedTable($table);
192-
$reference = $schema === '' ? $view : $schema . '.' . $view;
206+
$views = [];
193207

194-
return $connection->getSchemaBuilder()->hasView($reference);
208+
$viewSchema = $schema === '' && $connection->getDriverName() === 'sqlite'
209+
? 'main'
210+
: ($schema === '' ? null : $schema);
211+
212+
foreach ($connection->getSchemaBuilder()->getViews($viewSchema) as $view) {
213+
$views[strtolower($view['name'])] = true;
214+
}
215+
216+
$this->viewNames[$key] = $views;
217+
218+
return isset($views[strtolower((string) $connection->getTablePrefix() . $this->unqualifiedTable($table))]);
195219
} catch (\Throwable) {
196220
return null;
197221
}
198222
}
199223

200224
private function mutableConnectionKey(Connection $connection): string
201225
{
202-
$name = (string) $connection->getName();
203-
$configFingerprint = hash('xxh128', serialize([
204-
$connection->getConfig('search_path'),
205-
$connection->getConfig('schema'),
206-
$connection->getConfig('username'),
207-
]));
208-
209-
return $name . ':' . spl_object_id($connection) . ':' . hash(
210-
'xxh128',
211-
TableIdentity::encodeFields([
212-
(string) $connection->getDriverName(),
213-
(string) $connection->getDatabaseName(),
214-
(string) $connection->getTablePrefix(),
215-
$configFingerprint,
216-
]),
217-
);
226+
return (string) $connection->getName() . ':' . spl_object_id($connection);
218227
}
219228

220229
private function unqualifiedTable(string $table): string

tests/Integration/CacheUnavailableTest.php

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace NormCache\Tests\Integration;
44

5+
use Illuminate\Database\Events\MigrationsEnded;
56
use Illuminate\Support\Facades\DB;
67
use NormCache\Planning\TableIdentityResolver;
78
use NormCache\Support\RedisStore;
@@ -33,14 +34,41 @@ public function test_first_read_fails_open_when_redis_is_not_configured(): void
3334

3435
public function test_cache_disabled_status_fails_open_when_redis_is_unavailable(): void
3536
{
36-
$this->app->instance(
37-
RedisStore::class,
38-
new RedisStore('missing-normcache-connection'),
39-
);
40-
$this->app->forgetScopedInstances();
37+
$store = $this->app->make(RedisStore::class);
38+
39+
try {
40+
$this->app->instance(
41+
RedisStore::class,
42+
new RedisStore('missing-normcache-connection'),
43+
);
44+
$this->app->forgetScopedInstances();
45+
46+
$this->assertFalse($this->cacheManager()->cacheDisabled());
47+
$this->assertFalse($this->app->make(RuntimeState::class)->available());
48+
} finally {
49+
$this->app->instance(RedisStore::class, $store);
50+
$this->app->forgetScopedInstances();
51+
}
52+
}
53+
54+
public function test_completed_migrations_do_not_fail_when_redis_is_unavailable(): void
55+
{
56+
$store = $this->app->make(RedisStore::class);
57+
58+
try {
59+
$this->app->instance(
60+
RedisStore::class,
61+
new RedisStore('missing-normcache-connection'),
62+
);
63+
$this->app->forgetScopedInstances();
64+
65+
$this->app['events']->dispatch(new MigrationsEnded('up'));
4166

42-
$this->assertFalse($this->cacheManager()->cacheDisabled());
43-
$this->assertFalse($this->app->make(RuntimeState::class)->available());
67+
$this->assertFalse($this->app->make(RuntimeState::class)->available());
68+
} finally {
69+
$this->app->instance(RedisStore::class, $store);
70+
$this->app->forgetScopedInstances();
71+
}
4472
}
4573

4674
public function test_first_write_fails_open_when_redis_is_not_configured(): void

tests/Integration/CanonicalReadTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ public function test_canonical_list_populates_rows_reused_by_direct_pk_reads():
4646
$this->assertSame([], DB::getQueryLog());
4747
}
4848

49+
public function test_implicit_and_explicit_wildcards_share_the_canonical_cache_entry(): void
50+
{
51+
$expected = DB::table('posts')->orderBy('id')->get();
52+
53+
DB::flushQueryLog();
54+
DB::enableQueryLog();
55+
$actual = DB::table('posts')->select('*')->orderBy('id')->get();
56+
DB::disableQueryLog();
57+
58+
$this->assertSame(
59+
$expected->map(static fn(object $row): array => (array) $row)->all(),
60+
$actual->map(static fn(object $row): array => (array) $row)->all(),
61+
);
62+
$this->assertSame([], DB::getQueryLog());
63+
}
64+
4965
public function test_canonical_row_payload_must_match_the_primary_key_in_its_key(): void
5066
{
5167
$secondId = DB::table('posts')->insertGetId([

tests/Integration/DependencyVectorTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ public function hasView($view)
451451
public function test_database_views_require_explicit_table_dependencies(): void
452452
{
453453
DB::statement('create view post_titles as select id, title from posts');
454+
$this->cacheManager()->clearSchemaMetadata();
454455

455456
$implicit = fn() => DB::table('post_titles')->where('id', $this->postId)->first();
456457
$this->assertSame('Post', $implicit()?->title);

tests/Integration/EpochInvalidationTest.php

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,10 @@
66
use Illuminate\Support\Facades\DB;
77
use NormCache\Facades\NormCache;
88
use NormCache\Tests\TestCase;
9+
use NormCache\Values\CacheConfig;
910

1011
final class EpochInvalidationTest extends TestCase
1112
{
12-
/**
13-
* Regression guard: a write must not seed the scope's epoch memo. When it did, the
14-
* epoch captured before a concurrent flush was reused to stamp published payloads,
15-
* and the flush's own INCR could land on that same value.
16-
*/
1713
public function test_flush_all_evicts_a_pk_value_read(): void
1814
{
1915

@@ -35,6 +31,30 @@ public function test_flush_all_evicts_a_pk_value_read(): void
3531
$this->assertSame('Changed', $read());
3632
}
3733

34+
public function test_completed_migrations_advance_the_epoch_while_cache_is_disabled_by_configuration(): void
35+
{
36+
$epochKey = $this->cacheKeys()->epoch();
37+
$before = (int) ($this->cacheStore()->getRaw($epochKey) ?? '0');
38+
$original = $this->app->make(CacheConfig::class);
39+
$config = (array) config('normcache');
40+
$config['enabled'] = false;
41+
42+
$this->app->instance(CacheConfig::class, CacheConfig::fromArray($config));
43+
$this->app->forgetScopedInstances();
44+
45+
try {
46+
$this->app['events']->dispatch(new MigrationsEnded('up'));
47+
} finally {
48+
$this->app->instance(CacheConfig::class, $original);
49+
$this->app->forgetScopedInstances();
50+
}
51+
52+
$this->assertSame(
53+
$before + 1,
54+
(int) $this->cacheStore()->getRaw($epochKey),
55+
);
56+
}
57+
3858
public function test_completed_migrations_advance_the_epoch(): void
3959
{
4060
DB::table('authors')->insert(['id' => 1, 'name' => 'Author']);

0 commit comments

Comments
 (0)