Skip to content

Commit fb6a038

Browse files
committed
auto projection overlay / update docs
1 parent 4df2cea commit fb6a038

10 files changed

Lines changed: 102 additions & 105 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10-
## [4.0.0] — 2026-07-29
10+
## [4.0.0] — 2026-07-20
1111

1212
### Added
1313

1414
- **Query Builder caching:** supported `DB::table()` reads now participate in NormCache automatically, alongside Eloquent reads. Writes through those cache-aware builders invalidate affected tables automatically.
1515
- **Unified dependencies:** `dependsOn()` now accepts Eloquent model classes and raw table names in one declaration.
16-
- **Result overlays:** `useResultCache()` adds a complete-result payload on top of canonical row storage, letting warm reads avoid row-by-row assembly while retaining canonical fallback and repair.
16+
- **Automatic Result Overlays:** canonical queries returning up to `auto_overlay_max_rows` (default 50) automatically store a complete result payload in Redis, letting warm reads execute in a single Redis `GET` (~80–120 µs) while retaining canonical fallback and self-healing.
1717
- **Global tags:** use `tag('name')` to group cached query payloads and `NormCache::flushTag('name')` to invalidate that group.
1818
- **Unified manual invalidation:** `NormCache::invalidate()` accepts a model instance, model class, table name, or an array of those targets. A model target uses its model connection; table targets use Laravel's default connection unless `connection:` is supplied.
1919
- **Runtime cache switch:** `normcache:disable` and `normcache:enable`, plus `disableCache()`, `enableCache()`, and `cacheDisabled()`, pause caching across application nodes. Re-enabling advances the global epoch before serving cached data again.

README.md

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,6 @@ Publish the configuration:
2323
php artisan vendor:publish --tag=normcache-config
2424
```
2525

26-
### Optional igbinary serialization
27-
28-
When the `ext-igbinary` PHP extension is available, NormCache detects it automatically and uses it for cached payloads. Otherwise it falls back to PHP's native serialization; no configuration is required.
29-
30-
Every application node and worker sharing the same Redis cache must use the same serializer. After installing or removing igbinary, run `php artisan normcache:flush` before serving traffic so payloads written with the previous format are not reused.
31-
3226
Add `Cacheable` to Eloquent models whose writes and reads NormCache should observe:
3327

3428
```php
@@ -58,10 +52,23 @@ Cache controls are available on Eloquent and Query Builder:
5852
Post::query()->withoutCache()->get();
5953
Post::query()->where('published', true)->ttl(600)->get();
6054
Post::query()->where('published', true)->tag('homepage')->get();
61-
Post::query()->orderBy('id')->useResultCache()->get();
6255
```
6356

64-
For queries that would normally use canonical storage, `useResultCache()` also stores the complete result as one payload. Warm reads can use that payload directly, while canonical storage remains available as a fallback.
57+
## Canonical & Normalized Row Caching
58+
59+
At the core of NormCache is **normalized row storage**. Unlike traditional query caching—which stores duplicate, static copies of entire result sets for every unique SQL query—NormCache normalizes data in Redis:
60+
61+
- **Single Storage for Model Rows**: Individual database rows are stored once under canonical primary key IDs (`table:r:<id>`).
62+
- **Lightweight Query Memberships**: Queries cache only a list of primary key IDs (`table:m:<query_hash>`), not full duplicate model attributes.
63+
- **$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.
65+
66+
## Automatic Result & Projection Overlay
67+
68+
NormCache automatically optimizes warm query performance by storing single-step result overlays in Redis for eligible canonical queries:
69+
70+
- **Automatic Promotion**: Canonical queries returning up to `auto_overlay_max_rows` (default `50`) automatically store a serialized result payload in Redis (`table:e:v1:...`).
71+
- **Instant Synchronization & Self-Healing**: Updates to underlying models or dependency tables instantly invalidate the overlay alongside canonical storage. If an overlay key expires or misses, NormCache seamlessly falls back to canonical row assembly and repromotes automatically.
6572

6673
## Tags and selective flushing
6774

@@ -84,8 +91,6 @@ NormCache::flushTag('homepage');
8491

8592
`flushTag()` advances a Redis version counter; it does not scan for or delete matching keys. The affected queries miss and rebuild on their next read, while old payloads expire naturally. Tags are an additional manual invalidation boundary and do not replace automatic dependency invalidation when an underlying table changes.
8693

87-
Tags must be non-empty valid UTF-8 strings of at most 128 bytes.
88-
8994
## Dependencies
9095

9196
NormCache infers identifiable tables from ordinary joins, unions, subqueries, and relationship queries. If a query contains an opaque expression or source, declare every table it reads:
@@ -106,12 +111,7 @@ Writes through cache-aware Eloquent or Query Builder paths invalidate automatica
106111
Use the facade after writes performed elsewhere:
107112

108113
```php
109-
use App\Models\Comment;
110-
use App\Models\Post;
111-
use NormCache\Facades\NormCache;
112-
113-
NormCache::invalidate('posts', connection: 'mysql');
114-
NormCache::invalidate([Post::class, Comment::class]);
114+
NormCache::invalidate([Post::class, Comment::class], connection: 'mysql');
115115
NormCache::invalidate(['posts', 'comments'], connection: 'mysql');
116116
NormCache::flushTag('homepage');
117117
NormCache::flushAll();
@@ -138,18 +138,6 @@ While disabled, reads bypass NormCache and go directly to the database, and writ
138138

139139
`normcache:enable` atomically advances the global epoch before clearing the disabled flag. This prevents payloads cached before the pause from being served after writes occurred while invalidation was disabled.
140140

141-
The same controls are available programmatically:
142-
143-
```php
144-
use NormCache\Facades\NormCache;
145-
146-
NormCache::disableCache();
147-
$disabled = NormCache::cacheDisabled();
148-
$newEpoch = NormCache::enableCache();
149-
```
150-
151-
This runtime switch is separate from `NORMCACHE_ENABLED=false`. A cache disabled in configuration cannot be enabled with `normcache:enable`; update the configuration first.
152-
153141
## Configuration
154142

155143
```php
@@ -160,6 +148,7 @@ return [
160148

161149
'row_ttl' => 604800,
162150
'query_ttl' => 3600,
151+
'auto_overlay_max_rows' => 50,
163152

164153
'max_precise_invalidation_keys' => 1000,
165154
'building_lock_ttl' => 5,
@@ -201,12 +190,7 @@ NormCache bypasses reads when correctness cannot be established, including:
201190

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

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.
205-
206-
### Consistency & Failure Modes
207-
208-
- **Fail-Open Invalidation**: Database availability is prioritized over cache state. If Redis is unreachable during a write operation, NormCache fails open on the writing node (bypassing cache for subsequent reads on that node) and logs a warning. Note that other application nodes connected to Redis may continue serving cached queries until TTL expiration or subsequent invalidation.
209-
- **Triggers & Database Cascades**: Foreign key `ON DELETE CASCADE` rules, database triggers, and stored procedures operating within the database engine are not intercepted at the application layer. When executing operations that trigger database-side side-effect updates, call `NormCache::invalidate([...])` explicitly for affected secondary tables.
193+
Direct database writes executed outside of Eloquent (such as raw SQL, triggers, or external services) bypass automatic cache interception. Use `NormCache::invalidate(...)` or `NormCache::flushAll()` to manually invalidate affected models or tables. If connection schemas or table definitions are modified at runtime, call `NormCache::clearSchemaMetadata($connection)` to reset cached schema metadata.
210194

211195
## Redis Cluster
212196

@@ -216,6 +200,12 @@ All keys for one physical table share a Redis hash slot. Query-group entries use
216200

217201
When `events` is enabled, NormCache dispatches cache hit, miss, bypass, repair, and invalidation events. When `fruitcake/laravel-debugbar` is installed and `debugbar` is enabled, cache activity appears in Laravel Debugbar.
218202

203+
## Optional igbinary serialization
204+
205+
When the `ext-igbinary` PHP extension is available, NormCache detects it automatically and uses it for cached payloads. Otherwise it falls back to PHP's native serialization; no configuration is required.
206+
207+
Every application node and worker sharing the same Redis cache must use the same serializer. After installing or removing igbinary, run `php artisan normcache:flush` before serving traffic so payloads written with the previous format are not reused.
208+
219209
## License
220210

221211
MIT

config/normcache.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
'row_ttl' => (int) env('NORMCACHE_ROW_TTL', 604800),
99
'query_ttl' => (int) env('NORMCACHE_QUERY_TTL', 3600),
10+
'auto_overlay_max_rows' => (int) env('NORMCACHE_AUTO_OVERLAY_MAX_ROWS', 50),
1011

1112
// Each group requires connection, database, and table metadata. Add schema to
1213
// restrict a PostgreSQL or SQL Server match. Types are integer or string.

src/Cache/Engine.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,10 @@ private function promoteResultPayload(
11871187
bool $wakeWaiters = true,
11881188
): bool {
11891189
try {
1190+
if (count($rows) > $this->config->maxAutoOverlayRows) {
1191+
return false;
1192+
}
1193+
11901194
$ttl = $query->configuredTtl() ?? $this->config->queryTtl;
11911195
$encoded = $this->codec->encode(
11921196
$rows,

src/Database/QueryBuilder.php

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ final class QueryBuilder extends Builder
3535

3636
private ?string $tag = null;
3737

38-
private bool $useResultCache = false;
39-
4038
/** @var array<string, DependencyDeclaration> */
4139
private array $dependencies = [];
4240

@@ -139,18 +137,6 @@ public function configuredTag(): ?string
139137
return $this->tag;
140138
}
141139

142-
public function useResultCache(): static
143-
{
144-
$this->useResultCache = true;
145-
146-
return $this;
147-
}
148-
149-
public function usesResultCache(): bool
150-
{
151-
return $this->useResultCache;
152-
}
153-
154140
/** @param array<mixed> $dependencies */
155141
public function dependsOn(array $dependencies): static
156142
{

src/Planning/QueryPlanner.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function plan(
9191
$root,
9292
$dependencies,
9393
$primaryKey,
94-
materializeResult: $query->usesResultCache(),
94+
materializeResult: $query->limit !== null,
9595
);
9696
}
9797

src/Values/CacheConfig.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public function __construct(
1111
public string $keyPrefix,
1212
public int $rowTtl,
1313
public int $queryTtl,
14+
public int $maxAutoOverlayRows,
1415
public array $primaryKeys,
1516
public int $maxPreciseInvalidationKeys,
1617
public int $buildingLockTtl,
@@ -32,6 +33,11 @@ public static function fromArray(array $values): self
3233

3334
$rowTtl = self::positive($values, 'row_ttl', 604_800);
3435
$queryTtl = self::positive($values, 'query_ttl', 3_600);
36+
$maxAutoOverlayRows = self::positive(
37+
$values,
38+
'auto_overlay_max_rows',
39+
50,
40+
);
3541
$maxPreciseInvalidationKeys = self::bounded(
3642
$values,
3743
'max_precise_invalidation_keys',
@@ -46,6 +52,7 @@ public static function fromArray(array $values): self
4652
keyPrefix: $keyPrefix,
4753
rowTtl: $rowTtl,
4854
queryTtl: $queryTtl,
55+
maxAutoOverlayRows: $maxAutoOverlayRows,
4956
primaryKeys: self::primaryKeys($values['primary_keys'] ?? []),
5057
maxPreciseInvalidationKeys: $maxPreciseInvalidationKeys,
5158
buildingLockTtl: $buildingLockTtl,

tests/Integration/ResultCacheStrategyTest.php

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ protected function setUp(): void
3434
}
3535
}
3636

37-
public function test_use_result_cache_materializes_result_over_canonical_storage(): void
37+
public function test_small_canonical_result_automatically_materializes_an_overlay(): void
3838
{
3939
$query = fn() => DB::table('posts')
4040
->where('published', true)
@@ -48,7 +48,6 @@ public function test_use_result_cache_materializes_result_over_canonical_storage
4848
->orderByDesc('views')
4949
->orderBy('id')
5050
->limit(4)
51-
->useResultCache()
5251
->get();
5352

5453
$cold = $query();
@@ -71,7 +70,6 @@ public function test_eloquent_forwards_use_result_cache_to_the_query_builder():
7170
->where('published', true)
7271
->orderByDesc('views')
7372
->limit(3)
74-
->useResultCache()
7573
->get();
7674

7775
$cold = $query();
@@ -85,22 +83,34 @@ public function test_eloquent_forwards_use_result_cache_to_the_query_builder():
8583
$this->assertSame([], DB::getQueryLog());
8684
}
8785

88-
public function test_large_result_is_published_without_an_admission_limit(): void
86+
public function test_result_larger_than_the_row_limit_is_not_promoted(): void
8987
{
90-
$title = str_repeat('x', 4_194_304 + 1_024);
91-
$id = DB::table('posts')->insertGetId([
92-
'title' => $title,
93-
'views' => 0,
94-
'published' => true,
95-
'author_id' => $this->authorId,
96-
'created_at' => now(),
97-
'updated_at' => now(),
98-
]);
99-
100-
$row = DB::table('posts')->where('id', $id)->select('title')->first();
101-
102-
$this->assertSame(strlen($title), strlen((string) $row?->title));
103-
$this->assertCount(1, $this->cacheKeysMatching(':e:v'));
88+
foreach (range(1, 50) as $index) {
89+
DB::table('posts')->insert([
90+
'title' => "Extra {$index}",
91+
'views' => $index,
92+
'published' => true,
93+
'author_id' => $this->authorId,
94+
'created_at' => now(),
95+
'updated_at' => now(),
96+
]);
97+
}
98+
99+
$query = fn() => DB::table('posts')
100+
->where('published', true)
101+
->orderBy('id')
102+
->limit(55)
103+
->get();
104+
105+
$this->assertCount(55, $query());
106+
$this->assertSame([], $this->cacheKeysMatching(':e:v'));
107+
108+
DB::flushQueryLog();
109+
DB::enableQueryLog();
110+
$this->assertCount(55, $query());
111+
DB::disableQueryLog();
112+
113+
$this->assertSame([], DB::getQueryLog());
104114
}
105115

106116
public function test_missing_result_overlay_falls_back_to_canonical_and_repromotes(): void
@@ -109,7 +119,6 @@ public function test_missing_result_overlay_falls_back_to_canonical_and_repromot
109119
->where('published', true)
110120
->orderBy('id')
111121
->limit(4)
112-
->useResultCache()
113122
->get();
114123

115124
$expected = $query()->pluck('id')->all();
@@ -132,7 +141,6 @@ public function test_corrupt_result_overlay_falls_back_to_canonical_and_self_hea
132141
->where('published', true)
133142
->orderBy('id')
134143
->limit(4)
135-
->useResultCache()
136144
->get();
137145

138146
$expected = $query()->pluck('id')->all();
@@ -162,7 +170,6 @@ public function test_write_invalidates_the_materialized_overlay(): void
162170
->where('published', true)
163171
->orderBy('id')
164172
->limit(4)
165-
->useResultCache()
166173
->get();
167174

168175
$before = $query();
@@ -185,7 +192,6 @@ public function test_tag_flush_invalidates_the_materialized_overlay(): void
185192
->orderBy('id')
186193
->limit(4)
187194
->tag('homepage')
188-
->useResultCache()
189195
->get();
190196

191197
$query();
@@ -207,7 +213,6 @@ public function test_dependency_version_invalidates_the_materialized_overlay():
207213
->where('published', true)
208214
->orderBy('id')
209215
->limit(4)
210-
->useResultCache()
211216
->get();
212217

213218
$query();
@@ -229,7 +234,6 @@ public function test_query_ttl_applies_to_membership_and_result_overlay(): void
229234
->orderBy('id')
230235
->limit(4)
231236
->ttl(30)
232-
->useResultCache()
233237
->get();
234238

235239
$connection = Redis::connection('normcache-test');

tests/Unit/CacheConfigTest.php

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,22 @@
99

1010
final class CacheConfigTest extends UnitTestCase
1111
{
12-
public function test_builds_configuration_contract(): void
12+
public function test_populates_default_configuration_values(): void
1313
{
14-
$config = CacheConfig::fromArray([
15-
'connection' => 'normcache-test',
16-
'key_prefix' => 'app:',
17-
'row_ttl' => 600,
18-
'query_ttl' => 60,
19-
'primary_keys' => [],
20-
'events' => true,
21-
]);
14+
$config = CacheConfig::fromArray([]);
2215

23-
$this->assertSame('normcache-test', $config->connection);
24-
$this->assertSame('app:', $config->keyPrefix);
25-
$this->assertSame(600, $config->rowTtl);
26-
$this->assertSame(60, $config->queryTtl);
16+
$this->assertSame('cache', $config->connection);
17+
$this->assertSame('', $config->keyPrefix);
18+
$this->assertSame(604_800, $config->rowTtl);
19+
$this->assertSame(3_600, $config->queryTtl);
20+
$this->assertSame(50, $config->maxAutoOverlayRows);
2721
$this->assertSame(1000, $config->maxPreciseInvalidationKeys);
28-
$this->assertFalse(property_exists($config, 'maxMembershipRows'));
29-
$this->assertFalse(property_exists($config, 'maxMembershipBytes'));
30-
$this->assertFalse(property_exists($config, 'maxCanonicalBytes'));
31-
$this->assertFalse(property_exists($config, 'maxResultBytes'));
32-
$this->assertTrue($config->dispatchEvents);
33-
$this->assertFalse(property_exists($config, 'cooldown'));
34-
$this->assertFalse(property_exists($config, 'deploymentIds'));
35-
$this->assertFalse(property_exists($config, 'publicationGuardMarginSeconds'));
36-
$this->assertFalse(property_exists($config, 'fallbackEnabled'));
22+
$this->assertSame(5, $config->buildingLockTtl);
23+
$this->assertSame(200, $config->stampedeWaitMs);
24+
$this->assertSame(64, $config->stampedeWakeTokens);
25+
$this->assertTrue($config->enabled);
26+
$this->assertFalse($config->dispatchEvents);
27+
$this->assertFalse($config->debugbar);
3728
}
3829

3930
public function test_rejects_hash_tag_characters_in_key_prefix(): void
@@ -60,6 +51,7 @@ public static function invalidSafetyValues(): array
6051
['building_lock_ttl', 0],
6152
['row_ttl', 0],
6253
['query_ttl', 0],
54+
['auto_overlay_max_rows', 0],
6355
];
6456
}
6557

0 commit comments

Comments
 (0)