Skip to content
Merged

V3 #7

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
e93d530
remove current sharding logic
kai-init Jun 26, 2026
0689f44
consolidate lua scripts
kai-init Jun 26, 2026
b0ba4ca
remove dead code / consolidate lua
kai-init Jun 26, 2026
56c1b5c
WIP
kai-init Jun 26, 2026
4e62481
replace members-set model tracking with versioned model keys
kai-init Jun 27, 2026
75045cf
Merge branch 'v3' of https://github.com/kai-init/laravel-normcache in…
kai-init Jun 27, 2026
c1d2d14
lua script name cleanup
kai-init Jun 27, 2026
c856f52
unify normalized readers / extract DependencyResolver
kai-init Jun 27, 2026
4534725
eject colon in connection name
kai-init Jun 27, 2026
a14a0d4
remove braces for slotting
kai-init Jun 27, 2026
72bb473
move slotting into cachebuilder
kai-init Jun 27, 2026
f3ab055
resolve and validate cache space per query
kai-init Jun 28, 2026
ccc72f6
resolved space on the plan
kai-init Jun 28, 2026
8fdf972
update version tracker
kai-init Jun 28, 2026
e1e943b
scope key building to the active space
kai-init Jun 28, 2026
f01d347
space aware invalidation
kai-init Jun 28, 2026
1dcec42
scope relation cache to the active space
kai-init Jun 28, 2026
f427e07
explain cross space warning
kai-init Jun 28, 2026
af24f58
optional per-space hash-tag placement override
kai-init Jun 28, 2026
e96e8a2
omit optional Lua KEYS / multispace tests
kai-init Jun 28, 2026
e3f594d
fix static cache cross-space contamination
kai-init Jun 28, 2026
c107397
flush pattern scans across cache spaces
kai-init Jun 28, 2026
9097e88
optimize cache-space validation and relation scoping
kai-init Jun 28, 2026
bf884e6
cover cache spaces in versions and relation caches
kai-init Jun 28, 2026
515c28f
fix CI tests
kai-init Jun 28, 2026
736425d
fix predis ci tests
kai-init Jun 28, 2026
91b099d
redis-backed cache space registry for cluster flushes
kai-init Jun 29, 2026
14bef40
fix versioned cache writes and cluster test
kai-init Jun 29, 2026
b185e68
fix guarded relation model writes and phpstan issues
kai-init Jun 29, 2026
57ddf46
fix relation version lookup and placement spaces
kai-init Jun 29, 2026
ee9bf10
simplify space scoping and versioned cache writes
kai-init Jun 29, 2026
dbe5984
extract BuildContext
kai-init Jun 29, 2026
eb5826c
remove unneeded guards
kai-init Jun 29, 2026
f8f649e
add space specific tests
kai-init Jun 29, 2026
4d562d4
invalidation / wake key colocation
kai-init Jul 2, 2026
4e699e0
fix connection inference, space deduplication, key builder reset, and…
kai-init Jul 2, 2026
e632f44
add empty parent relationship loading test
kai-init Jul 2, 2026
85dc351
fix dependsOn() accumulation / pivot invlidation
kai-init Jul 2, 2026
8cf70b5
fix lint
kai-init Jul 2, 2026
9fa83b5
flush instance before save() so Eloquent observers see a cache miss
kai-init Jul 2, 2026
b98a43c
fix cache invalidation and space handling issues
kai-init Jul 3, 2026
31c0cfe
refactor cache internals and fix stale cooldown toggle in readers
kai-init Jul 5, 2026
5fca762
Decouple pure unit tests from Redis setup
kai-init Jul 6, 2026
51a9027
centralize Lua script marshalling in RedisStore
kai-init Jul 5, 2026
ec32d36
refactor cache internals into typed value objects and RedisScanner
kai-init Jul 6, 2026
01555f1
move dependency inference into planner
kai-init Jul 6, 2026
338bea9
update readme / changelog for v3
kai-init Jul 6, 2026
3ae3006
split ModelHydrator statics into RawAttributes/ScalarTransformer / ty…
kai-init Jul 9, 2026
de92495
fix table space registration
kai-init Jul 9, 2026
bd1f047
rename fallbackHandler to cacheFallback
kai-init Jul 10, 2026
56d0d7e
Merge branch 'v3' of https://github.com/kai-init/laravel-normcache in…
kai-init Jul 10, 2026
9c235d5
simplify cache planning internals and remove unused planner metadata
kai-init Jul 10, 2026
0a929f9
replace cache readers with repositories and expose manager collaborators
kai-init Jul 10, 2026
ab9a6b7
quick denpendency resolver fix
kai-init Jul 10, 2026
9823679
Merge branch 'v3' of https://github.com/kai-init/laravel-normcache in…
kai-init Jul 10, 2026
88f76a5
Fix unit test
kai-init Jul 10, 2026
843db74
mget for predis cluster
kai-init Jul 10, 2026
de84c4f
centralize cache construction / extract space validation
kai-init Jul 10, 2026
2d82632
fix test with cross slot mget
kai-init Jul 13, 2026
5c4d840
remove class static binding to use singleton
kai-init Jul 13, 2026
e8ee3db
remove belongsTo / morphTo relation override
kai-init Jul 13, 2026
5992257
detect active soft delete scope
kai-init Jul 13, 2026
264919b
use scoped lifecycle state / use built-in laravel functions
kai-init Jul 13, 2026
c6990cf
simplify cache lifecycle, flush scanning, and space validation
kai-init Jul 13, 2026
50de2da
fix distributed cache space invalidation
kai-init Jul 13, 2026
2b6edad
restore cache recovery lifecycle and fail-open table space lookup
kai-init Jul 14, 2026
eeef2e2
make cache identities connection-aware
kai-init Jul 14, 2026
5b38a2f
table space memo
kai-init Jul 14, 2026
ffe2e9f
remove deadcode
kai-init Jul 14, 2026
195280b
separate space resolution bypass reasons
kai-init Jul 14, 2026
baf0106
switch to xxh128
kai-init Jul 14, 2026
4f40f24
simplify planner query lifecycle and relation tracking
kai-init Jul 14, 2026
118c98c
refactor table-first cache planning and invalidation
kai-init Jul 15, 2026
8071913
update tests
kai-init Jul 15, 2026
4b50662
update readme / changelog
kai-init Jul 15, 2026
5982889
update docs
kai-init Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [Unreleased]
## [3.0.0] — 2026-07-09

### Added

- **Atomic cache spaces for Redis Cluster:** declare `$normCacheSpaces`, select one with `->space()`, and run cache operations in one hash slot.
- **Space-targeted flushing:** use `NormCache::flushAll('space')` or `php artisan normcache:flush --space=...`.
- **Cache-space coverage:** relations, pivot data, and table dependencies participate in cache-space invalidation.

### Changed

- **BREAKING:** model-declared cache spaces replace the `cluster` and `slotting` configuration. Run `php artisan normcache:flush` before deploying.
- `dependsOnTables()` supports named cache spaces.

### Fixed

- Cache isolation across declared spaces and database connections, including `Model::on()` queries and writes.
- Repeated `dependsOn()` calls no longer discard previously declared model dependencies.
- Stale cache entries are not written when a cache rebuild races an invalidating write.
- Table-dependency invalidation remains visible across application workers.
- Eloquent observers see invalidated cache state while a model is being saved.

---

Expand Down
321 changes: 127 additions & 194 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
"scripts": {
"test": "vendor/bin/phpunit",
"test:cluster": "REDIS_CLUSTER=true REDIS_PORT=7010 vendor/bin/phpunit",
"lint": "vendor/bin/pint --test",
"format": "vendor/bin/pint",
"analyse": "vendor/bin/phpstan analyse --memory-limit=512M"
Expand Down
22 changes: 14 additions & 8 deletions config/normcache.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// Redis connection from config/database.php.
'connection' => env('NORMCACHE_CONNECTION', 'cache'),

// Master switch; false bypasses the cache.
// Boot-time master switch; false bypasses the cache.
'enabled' => env('NORMCACHE_ENABLED', true),

// Model attribute payload TTL (seconds).
Expand All @@ -16,10 +16,6 @@
// Prefix every NormCache key. Useful when sharing a Redis database.
'key_prefix' => env('NORMCACHE_PREFIX', ''),

// false keeps all keys in {nc}, preserving cross-model Lua atomicity on Cluster.
// true slots by model/table, spreading load but limiting atomic operations to each slot.
'slotting' => (bool) env('NORMCACHE_SLOTTING', false),

// Debounce automatic version bumps on write-heavy models; 0 bumps immediately (seconds).
'cooldown' => (int) env('NORMCACHE_COOLDOWN', 0),

Expand All @@ -32,9 +28,6 @@
// Wake tokens pushed when a cache build releases. Raise for high same-key concurrency.
'stampede_wake_tokens' => (int) env('NORMCACHE_STAMPEDE_WAKE_TOKENS', 64),

// Use Redis Cluster-safe command paths when the configured Redis connection is clustered.
'cluster' => (bool) env('NORMCACHE_CLUSTER', false),

// Dispatch cache hit, miss, and bypass events. Enable only if something consumes them.
'events' => (bool) env('NORMCACHE_EVENTS', false),

Expand All @@ -46,4 +39,17 @@

// Register the Laravel Debugbar collector for local cache inspection.
'debugbar' => env('NORMCACHE_DEBUGBAR', false),
// Redis Cluster sharding via model-declared cache spaces.
'spaces' => [
// Max spaces per model. Writes bump one version key per space.
'max_per_model' => 16,

// Cross-space dependency handling: 'bypass' or 'throw'.
'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'),

// Optional space => hash-tag override. Default: content => {nc:content}.
'placement' => [
// 'catalog' => ['hash_tag' => 'nc:catalog'],
],
],
];
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Local Redis for the test suite.
# redis — single node on 6379 (default `composer test`)
# redis-cluster — 6-node cluster (3 masters + 3 replicas) on 7010-7015,
# matching REDIS_CLUSTER_NODES in phpunit.xml (`composer test:cluster`)
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: ["redis-server", "--save", "", "--appendonly", "no"]

redis-cluster:
image: grokzen/redis-cluster:7.0.10
environment:
IP: 0.0.0.0
INITIAL_PORT: 7010
MASTERS: 3
SLAVES_PER_MASTER: 1
ports:
- "7010-7015:7010-7015"
3 changes: 3 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ parameters:
-
identifier: argument.type
path: src/Support/RedisStore.php
-
identifier: argument.type
path: src/Support/RedisScanner.php
-
identifier: trait.unused
path: src/Traits/Cacheable.php
Expand Down
80 changes: 6 additions & 74 deletions src/Cache/ExecutionEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,42 +11,6 @@

final class ExecutionEngine
{
/**
* @param callable(): ResultCacheResult $fetch
* @param callable(): (ResultCacheResult|null) $waitForBuild
* @param callable(ResultCacheResult): array{Collection, mixed} $onMiss
* @param callable(mixed, ResultCacheResult): void $onStore
* @param callable(ResultCacheResult): Collection $onHit
* @param callable(): Collection $onBuild
*/
public function runResult(
callable $fetch,
callable $waitForBuild,
callable $onMiss,
callable $onStore,
callable $onHit,
callable $onBuild,
): Collection {
$result = $fetch();

if ($result->status === CacheStatus::Building) {
$result = $waitForBuild();

if ($result === null) {
return $onBuild();
}
}

if ($result->status === CacheStatus::Hit) {
return $onHit($result);
}

[$models, $payload] = $onMiss($result);
$onStore($payload, $result);

return $models;
}

/**
* @param callable(): PivotCacheResult $fetch
* @param callable(): (PivotCacheResult|null) $waitForBuild
Expand Down Expand Up @@ -119,47 +83,15 @@ public function runScalar(
}

/**
* @param callable(): QueryCacheResult $fetch
* @param callable(): (QueryCacheResult|null) $waitForBuild
* @param callable(): Collection $onBuild
* @param callable(QueryCacheResult): Collection $onMiss
* @param callable(QueryCacheResult): Collection $onHit
*/
public function runNormalized(
callable $fetch,
callable $waitForBuild,
callable $onBuild,
callable $onMiss,
callable $onHit,
): Collection {
$result = $fetch();

if ($result->status === CacheStatus::Building) {
$result = $waitForBuild();

if ($result === null) {
return $onBuild();
}
}

if ($result->status === CacheStatus::Miss) {
return $onMiss($result);
}

return $onHit($result);
}

/**
* Same control flow as {@see self::runNormalized()}, kept as a separate method so call
* sites get ThroughCacheResult-typed callables instead of QueryCacheResult.
* @template TResult of QueryCacheResult|ThroughCacheResult
*
* @param callable(): ThroughCacheResult $fetch
* @param callable(): (ThroughCacheResult|null) $waitForBuild
* @param callable(): TResult $fetch
* @param callable(): (TResult|null) $waitForBuild
* @param callable(): Collection $onBuild
* @param callable(ThroughCacheResult): Collection $onMiss
* @param callable(ThroughCacheResult): Collection $onHit
* @param callable(TResult): Collection $onMiss
* @param callable(TResult): Collection $onHit
*/
public function runThrough(
public function runNormalized(
callable $fetch,
callable $waitForBuild,
callable $onBuild,
Expand Down
74 changes: 74 additions & 0 deletions src/Cache/ModelCacheRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace NormCache\Cache;

use NormCache\Support\CacheKeyBuilder;
use NormCache\Support\RedisStore;
use NormCache\Values\BuildHandle;
use NormCache\Values\CacheConfig;
use NormCache\Values\CacheSpace;

final class ModelCacheRepository
{
public function __construct(
private readonly RedisStore $store,
private readonly CacheKeyBuilder $keys,
private readonly VersionTracker $versions,
private readonly CacheConfig $config,
) {}

public function store(
string $modelClass,
array $modelAttrs,
?CacheSpace $space = null,
?string $connection = null,
): void {
$space ??= $this->keys->activeSpace();
$modelVersion = $this->versions->currentVersion($modelClass, $space, $connection);

$this->storeForVersion($modelClass, $modelAttrs, $modelVersion, $space, $connection);
}

public function storeForBuild(
string $modelClass,
array $modelAttrs,
BuildHandle $build,
?CacheSpace $space = null,
?string $connection = null,
): void {
$classKey = $this->keys->classKey($modelClass, $connection);
$index = array_search($this->keys->verKey($classKey, $space), $build->versionKeys, true);

if ($index === false || !isset($build->expectedVersions[$index])) {
return;
}

$this->storeForVersion($modelClass, $modelAttrs, (int) $build->expectedVersions[$index], $space, $connection);
}

public function storeForVersion(
string $modelClass,
array $modelAttrs,
int $expectedVersion,
?CacheSpace $space = null,
?string $connection = null,
): void {
if (empty($modelAttrs)) {
return;
}

$classKey = $this->keys->classKey($modelClass, $connection);
$attrsByKey = [];

foreach ($modelAttrs as $id => $attrs) {
$attrsByKey[$this->keys->modelPrefix($classKey, $expectedVersion, $space) . $id] = $attrs;
}

$this->store->setManyIfVersion(
$attrsByKey,
$this->config->ttl,
$this->keys->verKey($classKey, $space),
$expectedVersion,
);
}
}
Loading