Skip to content

Commit efa5fa6

Browse files
committed
throw critical exeption when invalidation failed
1 parent 6ddae77 commit efa5fa6

3 files changed

Lines changed: 53 additions & 3 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ At the core of NormCache is **normalized row storage**. Unlike traditional query
6161
- **Single Storage for Model Rows**: Individual database rows are stored once under canonical primary key IDs (`table:r:<id>`).
6262
- **Lightweight Query Memberships**: Queries cache only a list of primary key IDs (`table:m:<query_hash>`), not full duplicate model attributes.
6363
- **$O(1)$ Invalidation Without Redis SCAN**: When a model is updated or deleted, NormCache invalidates only that specific row key (`table:r:<id>`) and advances the table version counter (`table:v`). There are no expensive `KEYS` or `SCAN` commands in Redis.
64-
- **Global Row Freshness**: Every query reading Post #42 automatically receives the updated row data on its next fetch, ensuring instant consistency across all application queries without clearing individual query keys.
64+
- **Global Row Freshness**: While Redis is available for invalidation, every query reading Post #42 receives updated row data on its next fetch without clearing individual query keys.
6565

6666
## Automatic Result & Projection Overlay
6767

@@ -126,6 +126,16 @@ php artisan normcache:flush
126126

127127
`flushAll()` and the command advance a global epoch. Old payloads expire naturally; NormCache does not scan Redis keys.
128128

129+
## Redis invalidation outages
130+
131+
NormCache fails open when Redis is unavailable: the database write succeeds and the affected request bypasses cache access. If only that writer cannot reach Redis while other application nodes can still read it, those nodes can serve stale cached data until the affected entry expires or invalidation later succeeds. NormCache logs this condition at `critical` level with the affected table and invalidation mode.
132+
133+
After Redis connectivity is restored, run the global flush to advance the epoch and make any payloads from the outage unreachable:
134+
135+
```bash
136+
php artisan normcache:flush
137+
```
138+
129139
## Temporarily disabling the cache
130140

131141
Use the runtime commands when NormCache needs to be paused across all application nodes without changing configuration or redeploying:

src/Invalidator.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use NormCache\Values\CacheConfig;
1414
use NormCache\Values\PrimaryKeyMetadata;
1515
use NormCache\Values\TableIdentity;
16+
use Psr\Log\LoggerInterface;
1617

1718
final class Invalidator
1819
{
@@ -28,6 +29,7 @@ public function __construct(
2829
private readonly PrimaryKeyResolver $primaryKeys,
2930
private readonly MutationKeyExtractor $mutationKeys,
3031
private readonly Reporter $reporter,
32+
private readonly LoggerInterface $logger,
3133
) {}
3234

3335
/** @param array<string, mixed>|null $assigned */
@@ -130,9 +132,9 @@ private function pullInvalidations(string $connection): array
130132
/** @param list<string> $tokens */
131133
private function apply(TableIdentity $table, bool $broad, array $tokens): bool
132134
{
133-
try {
134-
$mode = $broad ? 'generation' : ($tokens === [] ? 'version' : 'precise');
135+
$mode = $broad ? 'generation' : ($tokens === [] ? 'version' : 'precise');
135136

137+
try {
136138
$this->store->invalidateTableState(
137139
versionKey: $this->keys->version($table),
138140
generationKey: $this->keys->generation($table),
@@ -144,6 +146,15 @@ private function apply(TableIdentity $table, bool $broad, array $tokens): bool
144146

145147
return true;
146148
} catch (\Throwable $exception) {
149+
$this->logger->critical(
150+
'NormCache invalidation failed; cached reads may be stale.',
151+
[
152+
'exception' => $exception,
153+
'table' => $table->connection . ':' . $table->qualifiedTable(),
154+
'mode' => $mode,
155+
'tokens' => $tokens,
156+
],
157+
);
147158
$this->runtime->fail($exception);
148159

149160
return false;

tests/Integration/CacheUnavailableTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use NormCache\Support\RedisStore;
1010
use NormCache\Tests\TestCase;
1111
use NormCache\Values\CacheConfig;
12+
use Psr\Log\LoggerInterface;
1213

1314
final class CacheUnavailableTest extends TestCase
1415
{
@@ -110,6 +111,34 @@ public function test_first_write_fails_open_when_redis_is_not_configured(): void
110111
);
111112
}
112113

114+
public function test_failed_invalidation_is_logged_as_critical(): void
115+
{
116+
$authorId = DB::table('authors')->insertGetId(['name' => 'Author']);
117+
DB::table('posts')->insert([
118+
'title' => 'Before',
119+
'author_id' => $authorId,
120+
]);
121+
122+
$store = $this->app->make(RedisStore::class);
123+
$logger = $this->createMock(LoggerInterface::class);
124+
$logger->expects($this->once())
125+
->method('critical');
126+
127+
try {
128+
$this->app->instance(LoggerInterface::class, $logger);
129+
$this->app->instance(
130+
RedisStore::class,
131+
new RedisStore('missing-normcache-connection'),
132+
);
133+
$this->app->forgetScopedInstances();
134+
135+
DB::table('posts')->where('id', 1)->update(['title' => 'After']);
136+
} finally {
137+
$this->app->instance(RedisStore::class, $store);
138+
$this->app->forgetScopedInstances();
139+
}
140+
}
141+
113142
public function test_disabled_cache_reads_do_not_suppress_write_invalidation(): void
114143
{
115144
DB::table('authors')->insert(['id' => 1, 'name' => 'Author']);

0 commit comments

Comments
 (0)