Skip to content
Draft
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
28 changes: 28 additions & 0 deletions library/iFixit/Matryoshka/Backend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 22 additions & 10 deletions library/iFixit/Matryoshka/Scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
101 changes: 101 additions & 0 deletions tests/AbstractBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
72 changes: 72 additions & 0 deletions tests/ScopeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading