Skip to content

Commit 6ddae77

Browse files
committed
Fix sqlite correctness gap
1 parent fb6a038 commit 6ddae77

20 files changed

Lines changed: 429 additions & 16 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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-
- **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.
16+
- **Automatic Result Overlays:** canonical queries returning up to `auto_overlay_max_rows + 1` rows automatically store a complete result payload in Redis when its encoded size is at most 50 KiB. The extra row accommodates Laravel's simple/cursor pagination lookahead; with the default value of 50, payloads containing up to 51 rows are eligible. No explicit SQL `LIMIT` is required.
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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ At the core of NormCache is **normalized row storage**. Unlike traditional query
6767

6868
NormCache automatically optimizes warm query performance by storing single-step result overlays in Redis for eligible canonical queries:
6969

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:...`).
70+
- **Automatic Promotion**: Canonical queries returning up to `auto_overlay_max_rows + 1` rows automatically store a serialized result payload in Redis (`table:e:v1:...`) when its encoded size is less than 50 KiB. With the default configuration of `50`, payloads containing up to 51 rows are eligible. An explicit SQL `LIMIT` is not required.
71+
- **Pagination lookahead allowance**: Laravel's `simplePaginate()` and `cursorPaginate()` fetch one extra row to detect a next page. The one-row allowance avoids excluding a 50-item page solely because its SQL result contains 51 rows. The complete payload, including the lookahead row, must still fit within 50 KiB.
7172
- **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.
7273

7374
## Tags and selective flushing
@@ -102,7 +103,7 @@ Author::query()
102103
->get();
103104
```
104105

105-
`dependsOn()` accepts Eloquent model classes and table names. It authorizes an otherwise opaque query only when NormCache can resolve all declared dependencies. Volatile expressions such as random, UUID, clock, connection-state, or sleep functions are never cached.
106+
`dependsOn()` accepts Eloquent model classes and table names. It authorizes an otherwise opaque query only when NormCache can resolve all declared dependencies. Recognized volatile expressions—including random, UUID, clock, connection-state, sequence-state, and sleep functionsare never cached.
106107

107108
## Invalidation
108109

@@ -148,6 +149,7 @@ return [
148149

149150
'row_ttl' => 604800,
150151
'query_ttl' => 3600,
152+
// Set to 0 to disable automatic result overlays. Admission allows this value plus one row for pagination lookahead; encoded overlays are capped at 50 KiB.
151153
'auto_overlay_max_rows' => 50,
152154

153155
'max_precise_invalidation_keys' => 1000,

config/normcache.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
'row_ttl' => (int) env('NORMCACHE_ROW_TTL', 604800),
99
'query_ttl' => (int) env('NORMCACHE_QUERY_TTL', 3600),
10+
11+
// Set to 0 to disable automatic result overlays. Admission allows one extra row for pagination lookahead; encoded overlays are capped at 50 KiB.
1012
'auto_overlay_max_rows' => (int) env('NORMCACHE_AUTO_OVERLAY_MAX_ROWS', 50),
1113

1214
// Each group requires connection, database, and table metadata. Add schema to

src/Cache/Engine.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
final readonly class Engine
2828
{
29+
private const MAX_AUTO_OVERLAY_BYTES = 50 * 1024;
30+
2931
public function __construct(
3032
private CacheConfig $config,
3133
private CacheRuntime $runtime,
@@ -395,7 +397,7 @@ private function read(
395397
?string $canonicalQueryHash = null,
396398
): CacheRead {
397399
if ($plan->route === QueryPlan::CANONICAL) {
398-
return $plan->materializeResult
400+
return $plan->materializeResult && $this->config->maxAutoOverlayRows > 0
399401
? $this->readCanonicalWithResultOverlay(
400402
$query,
401403
$plan,
@@ -1187,7 +1189,10 @@ private function promoteResultPayload(
11871189
bool $wakeWaiters = true,
11881190
): bool {
11891191
try {
1190-
if (count($rows) > $this->config->maxAutoOverlayRows) {
1192+
if (
1193+
$this->config->maxAutoOverlayRows === 0
1194+
|| count($rows) > $this->config->maxAutoOverlayRows + 1
1195+
) {
11911196
return false;
11921197
}
11931198

@@ -1199,6 +1204,10 @@ private function promoteResultPayload(
11991204
$sourceState->tag,
12001205
);
12011206

1207+
if (strlen($encoded) > self::MAX_AUTO_OVERLAY_BYTES) {
1208+
return false;
1209+
}
1210+
12021211
$resultState = new CacheState(
12031212
key: $this->keys->result(
12041213
$resultPlan->root,

src/Planning/DependencyAnalyzer.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,11 @@ private function hasVolatileSqlValue(array $values): bool
283283
private function isVolatileSql(string $sql): bool
284284
{
285285
return preg_match(
286-
'/\b(?:rand|random|randomblob|uuid|uuid_short|newid|newsequentialid|gen_random_uuid|uuid_generate_v[0-9]+|nextval|currval|lastval|last_insert_id|last_insert_rowid|changes|total_changes|row_count|found_rows|connection_id|pg_backend_pid|now|sysdate|getdate|sysdatetime|sysutcdatetime|utc_timestamp|utc_date|utc_time|curdate|curtime|clock_timestamp|statement_timestamp|transaction_timestamp|timeofday|sleep|pg_sleep|benchmark)\s*\(/i',
286+
'/\b(?:rand|random|randomblob|random_bytes|uuid|uuid_short|newid|newsequentialid|gen_random_uuid|gen_random_bytes|crypt_gen_random|uuid_generate_v[0-9]+|nextval|currval|lastval|setval|last_insert_id|last_insert_rowid|changes|total_changes|row_count|found_rows|connection_id|pg_backend_pid|txid_current|pg_current_xact_id|user|database|schema|current_schema|current_database|current_catalog|current_setting|inet_client_addr|inet_client_port|inet_server_addr|inet_server_port|suser_sname|original_login|host_name|app_name|session_context|context_info|current_request_id|now|sysdate|getdate|sysdatetime|sysutcdatetime|utc_timestamp|utc_date|utc_time|curdate|curtime|clock_timestamp|statement_timestamp|transaction_timestamp|timeofday|sleep|pg_sleep|pg_sleep_for|pg_sleep_until|benchmark)\s*\(/i',
287287
$sql,
288288
) === 1
289289
|| preg_match(
290-
'/\b(?:current_timestamp|current_date|current_time|localtimestamp|localtime|current_user|session_user|system_user)\b/i',
290+
'/\b(?:current_timestamp|current_date|current_time|localtimestamp|localtime|current_user|session_user|system_user|current_role|current_schema|current_database|current_catalog|current_path)\b/i',
291291
$sql,
292292
) === 1
293293
|| preg_match(

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->limit !== null,
94+
materializeResult: true,
9595
);
9696
}
9797

src/Planning/TableIdentityResolver.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,14 @@ private function doResolve(Connection $connection, string $from): ?TableIdentity
9696
return null;
9797
}
9898

99-
$isView = $this->isView($connection, $schema, $table);
99+
$resolvedTable = $this->unqualifiedTable($table);
100+
101+
if ($driver === 'sqlite') {
102+
$schema = strtolower($schema === '' ? 'main' : $schema);
103+
$resolvedTable = strtolower($resolvedTable);
104+
}
105+
106+
$isView = $this->isView($connection, $schema, $resolvedTable);
100107

101108
if ($isView === null) {
102109
return null;
@@ -108,7 +115,7 @@ private function doResolve(Connection $connection, string $from): ?TableIdentity
108115
database: $database,
109116
schema: $schema,
110117
prefix: (string) $connection->getTablePrefix(),
111-
table: $this->unqualifiedTable($table),
118+
table: $resolvedTable,
112119
isView: $isView,
113120
);
114121
}

src/Values/CacheConfig.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public static function fromArray(array $values): self
3333

3434
$rowTtl = self::positive($values, 'row_ttl', 604_800);
3535
$queryTtl = self::positive($values, 'query_ttl', 3_600);
36-
$maxAutoOverlayRows = self::positive(
36+
$maxAutoOverlayRows = self::nonNegative(
3737
$values,
3838
'auto_overlay_max_rows',
3939
50,
@@ -76,6 +76,18 @@ private static function positive(array $values, string $key, int $default): int
7676
return $value;
7777
}
7878

79+
/** @param array<string, mixed> $values */
80+
private static function nonNegative(array $values, string $key, int $default): int
81+
{
82+
$value = (int) ($values[$key] ?? $default);
83+
84+
if ($value < 0) {
85+
throw new \InvalidArgumentException("NormCache {$key} must be at least 0.");
86+
}
87+
88+
return $value;
89+
}
90+
7991
/** @param array<string, mixed> $values */
8092
private static function bounded(array $values, string $key, int $maximum): int
8193
{

src/Values/TableIdentity.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ public function qualifiedTable(): string
5656
'mysql', 'mariadb' => $this->database . '.' . $this->table,
5757
'pgsql' => $this->schema . '.' . $this->table,
5858
'sqlsrv' => $this->database . '.' . $this->schema . '.' . $this->table,
59+
'sqlite' => $this->schema === ''
60+
? $this->table
61+
: $this->schema . '.' . $this->table,
5962
default => $this->table,
6063
};
6164
}

tests/Integration/CanonicalProjectionFallbackTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public function test_projection_fallback_promotes_compact_result_payload(): void
6969
->where('published', true)
7070
->orderBy('id');
7171
$wildcard->get();
72+
$this->deleteResultOverlays();
7273

7374
$projected = fn() => DB::table('posts')
7475
->where('published', true)
@@ -107,6 +108,7 @@ public function test_corrupt_projected_result_falls_back_to_canonical_and_rebuil
107108
->where('published', true)
108109
->orderBy('id')
109110
->get();
111+
$this->deleteResultOverlays();
110112

111113
$projected = fn() => DB::table('posts')
112114
->where('published', true)

0 commit comments

Comments
 (0)