Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]
### Added
- Added `withoutCache()` for per-query bypass of read caching and write invalidation (see https://github.com/spiritix/lada-cache/pull/151)
- TTL jitter: positive cache TTLs are perturbed by ±N% (default 15%) before `SET EX` to avoid synchronized expiration of co-cached keys and the resulting DB miss wave (thundering herd). Configurable via `lada-cache.ttl_jitter_pct` or `LADA_CACHE_TTL_JITTER_PCT`. Set to `0` to disable (legacy behavior, exact TTLs).

## [6.1.1] - 2026-05-22
### Fixed
Expand Down
27 changes: 27 additions & 0 deletions config/lada-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@
*/
'expiration_time' => env('LADA_CACHE_EXPIRATION', 0),

/*
|--------------------------------------------------------------------------
| TTL Jitter Percentage
|--------------------------------------------------------------------------
|
| When many cache entries are written within the same second, they expire
| in lockstep one TTL later, producing a synchronized DB miss wave
| (thundering herd). To mitigate this, each positive TTL is perturbed by
| ±N% before SET EX, spreading expirations uniformly over a window of
| roughly (2 × jitterPct)% of the configured TTL.
|
| N = 0 : disabled (deterministic — legacy behavior).
| N = 15 : default, ±15% (~30% spread; covers most use cases).
| N = 100 : maximum spread (clamp ceiling).
|
| Notes:
| - Jitter only applies to positive TTLs. A value of 0 / null
| ("persist forever") is never perturbed.
| - Values outside [0, 100] are clamped silently. Negative values
| disable jitter; values above 100 cap at 100.
| - Trade-off: a non-zero jitter means an entry's actual TTL may be up
| to N% longer than configured. If you rely on exact TTL semantics
| (e.g. distributed locks, OTPs), set this to 0.
|
*/
'ttl_jitter_pct' => (int) env('LADA_CACHE_TTL_JITTER_PCT', 15),

/*
|--------------------------------------------------------------------------
| Cache Granularity
Expand Down
39 changes: 37 additions & 2 deletions src/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,45 +18,54 @@
* - `flush()` removes all keys for the Lada prefix and safely handles
* connection-level Redis prefixes (Predis/PhpRedis) by stripping the
* connection prefix before deletion and batching deletes (preferring UNLINK).
* - Positive TTLs receive ±N% random jitter (default ±15%) before SET EX so
* that keys cached within the same second do not expire in lockstep,
* avoiding a synchronized DB miss wave (thundering-herd guard).
*/
final class Cache
{
private readonly int $expirationTime;
private readonly int $jitterPct;

public function __construct(
private readonly Redis $redis,
private readonly Encoder $encoder,
?int $expirationTime = null,
?int $jitterPct = null,
) {
$this->expirationTime = $expirationTime ?? (int) config('lada-cache.expiration_time', 0);

Check failure on line 36 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Cannot cast mixed to int.
$rawJitter = $jitterPct ?? (int) config('lada-cache.ttl_jitter_pct', 15);

Check failure on line 37 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Cannot cast mixed to int.
// Clamp to [0, 100]; negative or excessive values would produce invalid TTLs.
$this->jitterPct = max(0, min(100, $rawJitter));
}

public function has(string $key): bool
{
return (bool) $this->redis->exists($this->redis->prefix($key));

Check failure on line 44 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Call to an undefined method Spiritix\LadaCache\Redis::exists().
}

public function set(string $key, array $tags, mixed $data): void

Check failure on line 47 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Method Spiritix\LadaCache\Cache::set() has parameter $tags with no value type specified in iterable type array.
{
$key = $this->redis->prefix($key);
$value = $this->encoder->encode($data);
$effectiveTtl = $this->applyJitter($this->expirationTime);

if ($this->expirationTime > 0) {
$this->redis->set($key, $value, 'EX', $this->expirationTime);
if ($effectiveTtl > 0) {
$this->redis->set($key, $value, 'EX', $effectiveTtl);

Check failure on line 54 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Call to an undefined method Spiritix\LadaCache\Redis::set().
} else {
$this->redis->set($key, $value);

Check failure on line 56 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Call to an undefined method Spiritix\LadaCache\Redis::set().
}

foreach ($tags as $tag) {
$this->redis->sadd($this->redis->prefix($tag), $key);

Check failure on line 60 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Parameter #1 $key of method Spiritix\LadaCache\Redis::prefix() expects string, mixed given.

Check failure on line 60 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Call to an undefined method Spiritix\LadaCache\Redis::sadd().
}
}

public function get(string $key): mixed
{
$encoded = $this->redis->get($this->redis->prefix($key));

Check failure on line 66 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Call to an undefined method Spiritix\LadaCache\Redis::get().

return $encoded === null ? null : $this->encoder->decode($encoded);

Check failure on line 68 in src/Cache.php

View workflow job for this annotation

GitHub Actions / tests (8.3)

Parameter #1 $data of method Spiritix\LadaCache\Encoder::decode() expects string|null, mixed given.
}

public function repairTagMembership(string $key, array $tags): void
Expand All @@ -72,6 +81,32 @@
}
}

/**
* Apply ±jitterPct random jitter to a positive TTL.
*
* Edge cases:
* - TTL <= 0 : returned unchanged (0/null mean "persist forever").
* - jitterPct = 0 : returned unchanged (deterministic / disabled).
* - delta rounds to 0 : returned unchanged (TTL too small to be perturbed).
* - Result is clamped to at least 1 second so jitter never produces a
* non-positive TTL that would silently downgrade SET EX into
* "persist forever".
*/
private function applyJitter(int $ttl): int
{
if ($ttl <= 0 || $this->jitterPct === 0) {
return $ttl;
}

$delta = (int) round($ttl * ($this->jitterPct / 100));

if ($delta <= 0) {
return $ttl;
}

return max(1, $ttl + random_int(-$delta, $delta));
}

public function flush(): void
{
try {
Expand Down
124 changes: 124 additions & 0 deletions tests/Unit/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,130 @@ public function testGetDecodesEncodedPayload(): void
$this->assertSame($original, $cache->get($key));
}

public function testJitterDisabledWritesExactTtl(): void
{
$key = 'jitter-off-key';
$prefixed = 'p:jitter-off-key';

$this->redis->del($prefixed);
$cache = new Cache($this->redis, $this->encoder, 300, 0);
$cache->set($key, ['t'], ['data' => 1]);

$ttl = (int) $this->redis->ttl($prefixed);

// Allow 1s drift for Redis RTT.
$this->assertGreaterThanOrEqual(299, $ttl);
$this->assertLessThanOrEqual(300, $ttl);
}

public function testJitter15StaysWithinExpectedBand(): void
{
$base = 1000;
$cache = new Cache($this->redis, $this->encoder, $base, 15);

// Sample many writes; each must land inside [base*0.85, base*1.15] = [850, 1150].
for ($i = 0; $i < 50; $i++) {
$key = "jitter-band-key-{$i}";
$prefixed = "p:jitter-band-key-{$i}";

$this->redis->del($prefixed);
$cache->set($key, ['t'], ['n' => $i]);

$ttl = (int) $this->redis->ttl($prefixed);

$this->assertGreaterThanOrEqual(849, $ttl, "Iteration {$i}: TTL {$ttl} below lower band");
$this->assertLessThanOrEqual(1150, $ttl, "Iteration {$i}: TTL {$ttl} above upper band");
}
}

public function testJitterActuallyVariesResults(): void
{
$cache = new Cache($this->redis, $this->encoder, 1000, 15);
$observed = [];

for ($i = 0; $i < 30; $i++) {
$key = "jitter-spread-key-{$i}";
$prefixed = "p:jitter-spread-key-{$i}";

$this->redis->del($prefixed);
$cache->set($key, ['t'], ['n' => $i]);
$observed[] = (int) $this->redis->ttl($prefixed);
}

// With ±15% on TTL=1000 and 30 samples, getting all identical values has
// probability ~(1/301)^29 ≈ 0 — if every sample is the same, jitter is
// silently disabled.
$this->assertGreaterThan(1, count(array_unique($observed)));
}

public function testJitterSkippedWhenTtlZero(): void
{
$key = 'jitter-zero-ttl-key';
$prefixed = 'p:jitter-zero-ttl-key';

$this->redis->del($prefixed);
$cache = new Cache($this->redis, $this->encoder, 0, 50);
$cache->set($key, ['t'], ['data' => 1]);

$ttl = (int) $this->redis->ttl($prefixed);

// -1 = key exists with no expiration; jitter must not touch the "persist forever" path.
$this->assertSame(-1, $ttl);
}

public function testJitterClampsNegativePctToZero(): void
{
$key = 'jitter-negative-pct-key';
$prefixed = 'p:jitter-negative-pct-key';

$this->redis->del($prefixed);
$cache = new Cache($this->redis, $this->encoder, 600, -100);
$cache->set($key, ['t'], ['data' => 1]);

$ttl = (int) $this->redis->ttl($prefixed);

// -100 clamps to 0 → deterministic behavior, not crash.
$this->assertGreaterThanOrEqual(599, $ttl);
$this->assertLessThanOrEqual(600, $ttl);
}

public function testJitterClampsExcessivePctTo100(): void
{
$key = 'jitter-excessive-pct-key';
$prefixed = 'p:jitter-excessive-pct-key';

$this->redis->del($prefixed);
$cache = new Cache($this->redis, $this->encoder, 100, 500);
$cache->set($key, ['t'], ['data' => 1]);

$ttl = (int) $this->redis->ttl($prefixed);

// 500 clamps to 100 → max ±100% jitter, so result ∈ [1, 200].
$this->assertGreaterThanOrEqual(1, $ttl);
$this->assertLessThanOrEqual(200, $ttl);
}

public function testJitterFloorClampsToOneSecond(): void
{
$cache = new Cache($this->redis, $this->encoder, 1, 100);

// TTL=1 with pct=100 could mathematically produce 0 (1 + (-1)); floor must keep ≥1
// so SET EX never silently downgrades to "persist forever".
for ($i = 0; $i < 20; $i++) {
$key = "jitter-floor-key-{$i}";
$prefixed = "p:jitter-floor-key-{$i}";

$this->redis->del($prefixed);
$cache->set($key, ['t'], ['data' => $i]);

$ttl = (int) $this->redis->ttl($prefixed);

// -1 would mean jitter produced ≤0 and degraded the SET; that's the bug we guard against.
$this->assertNotSame(-1, $ttl, "Iteration {$i}: TTL collapsed to -1 (persist-forever)");
$this->assertGreaterThanOrEqual(0, $ttl);
}
}

public function testFlushScansAndDeletesAllPrefixedKeys(): void
{
$pattern = 'p:*';
Expand Down
Loading