diff --git a/library/iFixit/Matryoshka/Backend.php b/library/iFixit/Matryoshka/Backend.php index 166940d..79b2819 100644 --- a/library/iFixit/Matryoshka/Backend.php +++ b/library/iFixit/Matryoshka/Backend.php @@ -144,6 +144,34 @@ public function getAndSet($key, callable $callback, int $expiration = 0, return $value; } + /** + * Like getAndSet, but uses add() instead of set() so the first writer + * wins in a race. If another caller already populated the key, the + * winning value is returned instead of the callback's value. + * + * If $callback returns Backend::MISS, the add() call won't happen. + * + * @template T + * @param callable():T $callback A callback to generate the cached value + * + * @return T the value from the cache or the callback + */ + public function getOrAdd($key, callable $callback, int $expiration = 0) { + $value = $this->get($key); + + if ($value === self::MISS) { + $value = $callback(); + + if ($value !== self::MISS) { + if (!$this->add($key, $value, $expiration)) { + $value = $this->get($key) ?? $value; + } + } + } + + return $value; + } + /** * Wrapper around getMultiple that uses the provided callback to retrieve * and populate the cache for any misses. diff --git a/library/iFixit/Matryoshka/Scope.php b/library/iFixit/Matryoshka/Scope.php index bbea6b2..6edd7dc 100644 --- a/library/iFixit/Matryoshka/Scope.php +++ b/library/iFixit/Matryoshka/Scope.php @@ -22,19 +22,31 @@ public function getPrefix() { } public function getScopePrefix(bool $reset = false, bool $generateOnMiss = true) { - if ($this->scopePrefix === null || $reset) { - $scopeValue = $reset ? self::MISS : $this->backend->get($this->getScopeKey()); - if ($scopeValue === self::MISS) { - if ($generateOnMiss) { - $scopeValue = substr(md5(microtime() . $this->scopeName), 0, 16); - $this->backend->set($this->getScopeKey(), $scopeValue); - } else { - return self::MISS; - } - } + if ($this->scopePrefix !== null && !$reset) { + return $this->scopePrefix; + } + + if ($reset) { + $scopeValue = substr(md5(microtime() . $this->scopeName), 0, 16); + $this->backend->set($this->getScopeKey(), $scopeValue); $this->scopePrefix = "{$scopeValue}-"; + return $this->scopePrefix; } + if ($generateOnMiss) { + $scopeValue = $this->backend->getOrAdd( + $this->getScopeKey(), + fn() => substr(md5(microtime() . $this->scopeName), 0, 16) + ); + $this->scopePrefix = "{$scopeValue}-"; + return $this->scopePrefix; + } + + $scopeValue = $this->backend->get($this->getScopeKey()); + if ($scopeValue === self::MISS) { + return self::MISS; + } + $this->scopePrefix = "{$scopeValue}-"; return $this->scopePrefix; } diff --git a/tests/AbstractBackendTest.php b/tests/AbstractBackendTest.php index d12c56b..df8e5d2 100644 --- a/tests/AbstractBackendTest.php +++ b/tests/AbstractBackendTest.php @@ -284,6 +284,86 @@ function() { return null; }, 0, $reset = true); $this->assertSame($value, $backend->get($key)); } + public function testgetOrAdd() { + $backend = $this->getBackend(); + list($key, $value) = $this->getRandomKeyValue(); + + $this->assertNull($backend->get($key)); + + $miss = false; + $callback = function() use ($value, &$miss) { + $miss = true; + return $value; + }; + + // Miss: callback is invoked and value is added. + $result = $backend->getOrAdd($key, $callback); + + $this->assertTrue($miss); + $this->assertSame($value, $result); + $this->assertSame($value, $backend->get($key)); + + // Hit: callback is not invoked. + $miss = false; + $result = $backend->getOrAdd($key, $callback); + + $this->assertFalse($miss); + $this->assertSame($value, $result); + $this->assertSame($value, $backend->get($key)); + + // Key already exists: first writer wins, value is not overwritten. + list(, $newValue) = $this->getRandomKeyValue(); + $result = $backend->getOrAdd($key, function() use ($newValue) { + return $newValue; + }); + + $this->assertSame($value, $result); + $this->assertSame($value, $backend->get($key)); + + // Null callback return: add() is not called, null is returned. + list($key2) = $this->getRandomKeyValue(); + $result = $backend->getOrAdd($key2, function() { return null; }); + + $this->assertNull($result); + $this->assertNull($backend->get($key2)); + } + + public function testGetOrAddFallbackValue() { + $backend = new class extends Matryoshka\Ephemeral { + public function add($key, $value, $expiration = 0) { + return false; + } + }; + + [$key] = $this->getRandomKeyValue(); + $computedValue = 'computed'; + + $result = $backend->getOrAdd($key, function() use ($computedValue) { + return $computedValue; + }); + + $this->assertSame($computedValue, $result); + } + + public function testGetOrAddFirstWriterWins() { + $racing = new RacingBackend($this->getBackend()); + [$key] = $this->getRandomKeyValue(); + + $firstWriterValue = 'first-writer'; + $secondWriterValue = 'second-writer'; + + $racing->afterNextGet(function($key) use ($racing, $firstWriterValue) { + $racing->set($key, $firstWriterValue); + }); + + $result = $racing->getOrAdd($key, function() use ($secondWriterValue) { + return $secondWriterValue; + }); + + $this->assertSame($firstWriterValue, $result); + $this->assertSame($firstWriterValue, $racing->get($key)); + } + public function testgetAndSetMultiple() { $backend = $this->getBackend(); list($key1, $value1, $id1) = $this->getRandomKeyValueId(); @@ -490,6 +570,27 @@ protected function isCharExemptFromKeyEquivalence($char) { } } +/** + * Simulates a concurrent writer on a shared backend by injecting + * behavior between get() returning and the caller acting on the result. + */ +class RacingBackend extends Matryoshka\BackendWrap { + private $afterNextGet = null; + + public function afterNextGet(callable $fn) { + $this->afterNextGet = $fn; + } + + public function get($key) { + $result = $this->backend->get($key); + if ($fn = $this->afterNextGet) { + $this->afterNextGet = null; + $fn($key); + } + return $result; + } +} + // Exposes the array of cached values. class TestEphemeral extends Matryoshka\Ephemeral { public function getCache() { diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index 49bb109..b6bf1c9 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -98,4 +98,76 @@ public function testAbsoluteKey() { $this->assertEquals($scopedCache->getScopePrefix() . $key, $scopedCache->getAbsoluteKey($key)); } + + public function testPrefixFirstWriterWins() { + $racing = new RacingBackend(new Matryoshka\Ephemeral()); + $scope = new Matryoshka\Scope($racing, 'test-scope'); + + $competitorPrefix = 'competitor-won'; + + $racing->afterNextGet(function($key) use ($racing, $competitorPrefix) { + $racing->set($key, $competitorPrefix); + }); + + $prefix = $scope->getScopePrefix(); + + $this->assertSame("{$competitorPrefix}-", $prefix); + $this->assertSame($competitorPrefix, $racing->get('scope-test-scope')); + } + + public function testPrefixInit() { + $inner = new Matryoshka\Ephemeral(); + $scope = new Matryoshka\Scope($inner, 'test-scope'); + + $prefix = $scope->getScopePrefix(); + + $this->assertNotEmpty($prefix); + $this->assertStringEndsWith('-', $prefix); + $this->assertSame($prefix, $scope->getScopePrefix()); + } + + public function testDeleteScopeOverwrites() { + $inner = new Matryoshka\Ephemeral(); + $scope = new Matryoshka\Scope($inner, 'test-scope'); + + $originalPrefix = $scope->getScopePrefix(); + $scope->deleteScope(); + $newPrefix = $scope->getScopePrefix(); + + $this->assertNotSame($originalPrefix, $newPrefix); + } + + public function testPrefixRacePreservesData() { + $racing = new RacingBackend(new Matryoshka\Ephemeral()); + $scope = new Matryoshka\Scope($racing, 'test-scope'); + + $competitorPrefix = 'competitor-won'; + + $racing->afterNextGet(function($key) use ($racing, $competitorPrefix) { + $racing->set($key, $competitorPrefix); + $racing->set("{$competitorPrefix}-user-data", 'competitor-data'); + }); + + $scope->getScopePrefix(); + + $this->assertSame('competitor-data', $scope->get('user-data')); + } + + /** + * If add() fails and the re-fetch also misses (e.g. the backend lost + * the key between add and get), the generated prefix is still used. + */ + public function testPrefixNonEmptyOnAddFailure() { + $backend = new class extends Matryoshka\Ephemeral { + public function add($key, $value, $expiration = 0) { + return false; + } + }; + $scope = new Matryoshka\Scope($backend, 'test-scope'); + + $prefix = $scope->getScopePrefix(); + + $this->assertNotSame('-', $prefix); + $this->assertStringEndsWith('-', $prefix); + } }