From de94c06190226c0832e9a052142613db5f12b0ca Mon Sep 17 00:00:00 2001 From: Daryl Metzler Date: Wed, 8 Apr 2026 11:28:14 -0700 Subject: [PATCH 1/5] Add a getAndAdd backend function Similar to `getAndAdd`, but atomic (first writer wins) Note that if we cannot fetch back the new first writer's value, we still fallback to returning the computed value to the caller. --- library/iFixit/Matryoshka/Backend.php | 28 +++++++++++++++++ tests/AbstractBackendTest.php | 44 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/library/iFixit/Matryoshka/Backend.php b/library/iFixit/Matryoshka/Backend.php index 166940d..e8f7a44 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 getAndAdd($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/tests/AbstractBackendTest.php b/tests/AbstractBackendTest.php index d12c56b..5e66bcd 100644 --- a/tests/AbstractBackendTest.php +++ b/tests/AbstractBackendTest.php @@ -284,6 +284,50 @@ function() { return null; }, 0, $reset = true); $this->assertSame($value, $backend->get($key)); } + public function testgetAndAdd() { + $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->getAndAdd($key, $callback); + + $this->assertTrue($miss); + $this->assertSame($value, $result); + $this->assertSame($value, $backend->get($key)); + + // Hit: callback is not invoked. + $miss = false; + $result = $backend->getAndAdd($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->getAndAdd($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->getAndAdd($key2, function() { return null; }); + + $this->assertNull($result); + $this->assertNull($backend->get($key2)); + } + public function testgetAndSetMultiple() { $backend = $this->getBackend(); list($key1, $value1, $id1) = $this->getRandomKeyValueId(); From 34a3e78d8af4cbb5c0de23c1c3a08151f5ac5582 Mon Sep 17 00:00:00 2001 From: Daryl Metzler Date: Wed, 8 Apr 2026 12:19:00 -0700 Subject: [PATCH 2/5] Use getAndAdd in Scope::getScopePrefix Update `getScopePrefix` to use `getAndAdd` instead. This switches the prefix fetch to always use the same scope prefix as the first writer. We can refactor a bit, for clarity. --- library/iFixit/Matryoshka/Scope.php | 32 ++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/library/iFixit/Matryoshka/Scope.php b/library/iFixit/Matryoshka/Scope.php index bbea6b2..c99b697 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->getAndAdd( + $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; } From a7ae18882ddb9f47f28a2bd975d5410a9aa6465f Mon Sep 17 00:00:00 2001 From: Daryl Metzler Date: Wed, 8 Apr 2026 13:11:07 -0700 Subject: [PATCH 3/5] Rename new function to `getOrAdd` Not `getAndAdd`, which is technically not correct. --- library/iFixit/Matryoshka/Backend.php | 2 +- library/iFixit/Matryoshka/Scope.php | 2 +- tests/AbstractBackendTest.php | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/library/iFixit/Matryoshka/Backend.php b/library/iFixit/Matryoshka/Backend.php index e8f7a44..79b2819 100644 --- a/library/iFixit/Matryoshka/Backend.php +++ b/library/iFixit/Matryoshka/Backend.php @@ -156,7 +156,7 @@ public function getAndSet($key, callable $callback, int $expiration = 0, * * @return T the value from the cache or the callback */ - public function getAndAdd($key, callable $callback, int $expiration = 0) { + public function getOrAdd($key, callable $callback, int $expiration = 0) { $value = $this->get($key); if ($value === self::MISS) { diff --git a/library/iFixit/Matryoshka/Scope.php b/library/iFixit/Matryoshka/Scope.php index c99b697..6edd7dc 100644 --- a/library/iFixit/Matryoshka/Scope.php +++ b/library/iFixit/Matryoshka/Scope.php @@ -34,7 +34,7 @@ public function getScopePrefix(bool $reset = false, bool $generateOnMiss = true) } if ($generateOnMiss) { - $scopeValue = $this->backend->getAndAdd( + $scopeValue = $this->backend->getOrAdd( $this->getScopeKey(), fn() => substr(md5(microtime() . $this->scopeName), 0, 16) ); diff --git a/tests/AbstractBackendTest.php b/tests/AbstractBackendTest.php index 5e66bcd..14c5be9 100644 --- a/tests/AbstractBackendTest.php +++ b/tests/AbstractBackendTest.php @@ -284,7 +284,7 @@ function() { return null; }, 0, $reset = true); $this->assertSame($value, $backend->get($key)); } - public function testgetAndAdd() { + public function testgetOrAdd() { $backend = $this->getBackend(); list($key, $value) = $this->getRandomKeyValue(); @@ -297,7 +297,7 @@ public function testgetAndAdd() { }; // Miss: callback is invoked and value is added. - $result = $backend->getAndAdd($key, $callback); + $result = $backend->getOrAdd($key, $callback); $this->assertTrue($miss); $this->assertSame($value, $result); @@ -305,7 +305,7 @@ public function testgetAndAdd() { // Hit: callback is not invoked. $miss = false; - $result = $backend->getAndAdd($key, $callback); + $result = $backend->getOrAdd($key, $callback); $this->assertFalse($miss); $this->assertSame($value, $result); @@ -313,7 +313,7 @@ public function testgetAndAdd() { // Key already exists: first writer wins, value is not overwritten. list(, $newValue) = $this->getRandomKeyValue(); - $result = $backend->getAndAdd($key, function() use ($newValue) { + $result = $backend->getOrAdd($key, function() use ($newValue) { return $newValue; }); @@ -322,7 +322,7 @@ public function testgetAndAdd() { // Null callback return: add() is not called, null is returned. list($key2) = $this->getRandomKeyValue(); - $result = $backend->getAndAdd($key2, function() { return null; }); + $result = $backend->getOrAdd($key2, function() { return null; }); $this->assertNull($result); $this->assertNull($backend->get($key2)); From 9bff39864330538daf64aad7d5e83af184c21c8f Mon Sep 17 00:00:00 2001 From: Daryl Metzler Date: Thu, 9 Apr 2026 11:05:27 -0700 Subject: [PATCH 4/5] Improve getOrAdd testing --- tests/AbstractBackendTest.php | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/AbstractBackendTest.php b/tests/AbstractBackendTest.php index 14c5be9..df8e5d2 100644 --- a/tests/AbstractBackendTest.php +++ b/tests/AbstractBackendTest.php @@ -328,6 +328,42 @@ public function testgetOrAdd() { $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(); @@ -534,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() { From e7e77540f85adfbdc34eb7fe6fc63c8eb3e15972 Mon Sep 17 00:00:00 2001 From: Daryl Metzler Date: Thu, 9 Apr 2026 11:06:42 -0700 Subject: [PATCH 5/5] Add Scope race condition test cases Better test how Scope handles (with `getOrAdd`) prefix race conditions. --- tests/ScopeTest.php | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) 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); + } }