Skip to content

Commit

Permalink
request-scoped evaluation cache implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
mredolatti committed Nov 3, 2023
1 parent 8797594 commit 0129531
Show file tree
Hide file tree
Showing 23 changed files with 575 additions and 20 deletions.
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
1.3.0 (TBD):
- Added in-memory evaluation cache for the duration of a request.

1.2.0 (Sep 19, 2023):
- Add support for Client/GetTreatment(s)WithConfig operations.
- Add support for Manager operations.
Expand Down
41 changes: 38 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace SplitIO\ThinSdk;

use \SplitIO\ThinSdk\Utils\ImpressionListener;
use \SplitIO\ThinSdk\Utils\EvalCache\Cache;
use \SplitIO\ThinSdk\Utils\EvalCache\NoCache;
use \SplitIO\ThinSdk\Utils\InputValidator\InputValidator;
use \SplitIO\ThinSdk\Utils\InputValidator\ValidationException;
use \SplitIO\ThinSdk\Models\Impression;
Expand All @@ -17,20 +19,27 @@ class Client implements ClientInterface
private /*LoggerInterface*/ $logger;
private /*?ImpressionListener*/ $impressionListener;
private /*InputValidator*/ $inputValidator;
private /*Cache*/ $cache;

public function __construct(Manager $manager, LoggerInterface $logger, ?ImpressionListener $impressionListener)
public function __construct(Manager $manager, LoggerInterface $logger, ?ImpressionListener $impressionListener, ?Cache $cache = null)
{
$this->logger = $logger;
$this->lm = $manager;
$this->impressionListener = $impressionListener;
$this->inputValidator = new InputValidator($logger);
$this->cache = $cache ?? new NoCache();
}

public function getTreatment(string $key, ?string $bucketingKey, string $feature, ?array $attributes = null): string
{
try {
if (($fromCache = $this->cache->get($key, $feature, $attributes)) != null) {
return $fromCache;
}

list($treatment, $ilData) = $this->lm->getTreatment($key, $bucketingKey, $feature, $attributes);
$this->handleListener($key, $bucketingKey, $feature, $attributes, $treatment, $ilData);
$this->cache->set($key, $feature, $attributes, $treatment);
return $treatment;
} catch (\Exception $exc) {
$this->logger->error($exc);
Expand All @@ -41,13 +50,21 @@ public function getTreatment(string $key, ?string $bucketingKey, string $feature
public function getTreatments(string $key, ?string $bucketingKey, array $features, ?array $attributes = null): array
{
try {
// try to fetch items from cache. return result if all evaluations are cached
// otherwise, send a Treatments RPC for missing ones and return merged result
$toReturn = $this->cache->getMany($key, $features, $attributes);
$features = self::getMissing($toReturn);
if (count($features) == 0) {
return $toReturn;
}

$results = $this->lm->getTreatments($key, $bucketingKey, $features, $attributes);
$toReturn = [];
foreach ($results as $feature => $result) {
list($treatment, $ilData) = $result;
$toReturn[$feature] = $treatment;
$this->handleListener($key, $bucketingKey, $feature, $attributes, $treatment, $ilData);
}
$this->cache->setMany($key, $attributes, $toReturn);
return $toReturn;
} catch (\Exception $exc) {
$this->logger->error($exc);
Expand All @@ -61,8 +78,14 @@ public function getTreatments(string $key, ?string $bucketingKey, array $feature
public function getTreatmentWithConfig(string $key, ?string $bucketingKey, string $feature, ?array $attributes = null): array
{
try {

if (($fromCache = $this->cache->getWithConfig($key, $feature, $attributes)) != null) {
return $fromCache;
}

list($treatment, $ilData, $config) = $this->lm->getTreatmentWithConfig($key, $bucketingKey, $feature, $attributes);
$this->handleListener($key, $bucketingKey, $feature, $attributes, $treatment, $ilData);
$this->cache->setWithConfig($key, $feature, $attributes, $treatment, $config);
return ['treatment' => $treatment, 'config' => $config];
} catch (\Exception $exc) {
$this->logger->error($exc);
Expand All @@ -73,13 +96,20 @@ public function getTreatmentWithConfig(string $key, ?string $bucketingKey, strin
public function getTreatmentsWithConfig(string $key, ?string $bucketingKey, array $features, ?array $attributes = null): array
{
try {
$toReturn = $this->cache->getManyWithConfig($key, $features, $attributes);
$features = self::getMissing($toReturn);

if (count($features) == 0) {
return $toReturn;
}

$results = $this->lm->getTreatmentsWithConfig($key, $bucketingKey, $features, $attributes);
$toReturn = [];
foreach ($results as $feature => $result) {
list($treatment, $ilData, $config) = $result;
$toReturn[$feature] = ['treatment' => $treatment, 'config' => $config];
$this->handleListener($key, $bucketingKey, $feature, $attributes, $treatment, $ilData);
}
$this->cache->setManyWithConfig($key, $attributes, $toReturn);
return $toReturn;
} catch (\Exception $exc) {
$this->logger->error($exc);
Expand Down Expand Up @@ -124,4 +154,9 @@ private function handleListener(string $key, ?string $bucketingKey, string $feat
$this->logger->error($exc);
}
}

private static function getMissing(array $results): array
{
return array_keys(array_filter($results, 'is_null'));
}
}
27 changes: 23 additions & 4 deletions src/Config/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,50 @@

namespace SplitIO\ThinSdk\Config;

use \SplitIO\ThinSdk\Utils\ImpressionListener;
use SplitIO\ThinSdk\Utils\ImpressionListener;
use SplitIO\ThinSdk\Utils\EvalCache\InputHasher;


class Utils
{
private /*?ImpressionListener*/ $listener;
private /*?string*/ $evaluationCache;
private /*?InputHasher*/ $customCacheHash;

private function __construct(?ImpressionListener $listener)
private function __construct(?ImpressionListener $listener, ?string $evaluationCache, ?InputHasher $customCacheHash)
{
$this->listener = $listener;
$this->evaluationCache = $evaluationCache;
$this->customCacheHash = $customCacheHash;
}

public function impressionListener(): ?ImpressionListener
{
return $this->listener;
}

public function evaluationCache(): ?string
{
return $this->evaluationCache;
}

public function customCacheHash(): ?InputHasher
{
return $this->customCacheHash;
}

public static function fromArray(array $config): Utils
{
$d = self::default();
return new Utils($config['impressionListener'] ?? $d->impressionListener());
return new Utils(
$config['impressionListener'] ?? $d->impressionListener(),
$config['evaluationCache'] ?? $d->evaluationCache(),
$config['customCacheHash'] ?? $d->customCacheHash(),
);
}

public static function default(): Utils
{
return new Utils(null);
return new Utils(null, 'none', null);
}
}
25 changes: 24 additions & 1 deletion src/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,34 @@ public static function withConfig(array $config): FactoryInterface

public function client(): ClientInterface
{
return new Client($this->linkManager, $this->logger, $this->config->utils()->impressionListener());
return new Client($this->linkManager, $this->logger, $this->config->utils()->impressionListener(), $this->getCache());
}

public function manager(): ManagerInterface
{
return new Manager($this->linkManager, $this->logger);
}

private function getCache(): Utils\EvalCache\Cache
{
$uc = $this->config->utils();
$cache = new Utils\EvalCache\NoCache();
switch ($uc->evaluationCache()) {
case 'key-only':
$cache = new Utils\EvalCache\CacheImpl(new Utils\EvalCache\KeyOnlyHasher());
break;
case 'key-attribute':
$cache = new Utils\EvalCache\CacheImpl(new Utils\EvalCache\KeyAttributeCRC32Hasher());
break;
case 'custom':
if (is_null($uc->customCacheHash())) {
$this->logger->error(sprintf(
"config indicates 'custom' evaluation cache hasher, but no 'customCacheHash' passed. Cache will be disabled"
));
} else {
$cache = new Utils\EvalCache\CacheImpl($uc->customCacheHash());
}
}
return $cache;
}
};
15 changes: 15 additions & 0 deletions src/Utils/EvalCache/Cache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace SplitIO\ThinSdk\Utils\EvalCache;

interface Cache
{
public function get(string $key, string $feature, ?array $attributes): ?string;
public function getMany(string $key, array $features, ?array $attributes): array;
public function getWithConfig(string $key, string $feature, ?array $attributes): ?array;
public function getManyWithConfig(string $key, array $features, ?array $attributes): array;
public function set(string $key, string $feature, ?array $attributes, string $treatment);
public function setMany(string $key, ?array $attributes, array $results);
public function setWithConfig(string $key, string $feature, ?array $attributes, string $treatment, ?string $config);
public function setManyWithConfig(string $key, ?array $attributes, array $results);
}
82 changes: 82 additions & 0 deletions src/Utils/EvalCache/CacheImpl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace SplitIO\ThinSdk\Utils\EvalCache;

class CacheImpl implements Cache
{

private /*array*/ $data;
private /*InputHasher*/ $hasher;

public function __construct(InputHasher $hasher)
{
$this->data = [];
$this->hasher = $hasher;
}

public function get(string $key, string $feature, ?array $attributes): ?string
{
$entry = $this->_get($key, $feature, $attributes);
return ($entry != null) ? $entry->getTreatment() : null;
}

public function getMany(string $key, array $features, ?array $attributes): array
{
$result = [];
foreach ($features as $feature) {
$result[$feature] = $this->get($key, $feature, $attributes);
}
return $result;
}

public function getWithConfig(string $key, string $feature, ?array $attributes): ?array
{
// if the entry exists but was previously fetched without config, it's returned as null,
// so that it's properly fetched by `getTreatmentWithConfig`
$entry = $this->_get($key, $feature, $attributes);
return ($entry != null && $entry->hasConfig())
? ['treatment' => $entry->getTreatment(), 'config' => $entry->getConfig()]
: null;
}

public function getManyWithConfig(string $key, array $features, ?array $attributes): array
{
$result = [];
foreach ($features as $feature) {
$result[$feature] = $this->getWithConfig($key, $feature, $attributes);
}
return $result;
}

public function set(string $key, string $feature, ?array $attributes, string $treatment)
{
$h = $this->hasher->hashInput($key, $feature, $attributes);
$this->data[$h] = new Entry($treatment, false);
}

public function setMany(string $key, ?array $attributes, array $results)
{
foreach ($results as $feature => $treatment) {
$this->set($key, $feature, $attributes, $treatment);
}
}

public function setWithConfig(string $key, string $feature, ?array $attributes, string $treatment, ?string $config)
{
$h = $this->hasher->hashInput($key, $feature, $attributes);
$this->data[$h] = new Entry($treatment, true, $config);
}

public function setManyWithConfig(string $key, ?array $attributes, array $results)
{
foreach ($results as $feature => $result) {
$this->setWithConfig($key, $feature, $attributes, $result['treatment'], $result['config']);
}
}

private function _get(string $key, string $feature, ?array $attributes): ?Entry
{
$h = $this->hasher->hashInput($key, $feature, $attributes);
return $this->data[$h] ?? null;
}
}
32 changes: 32 additions & 0 deletions src/Utils/EvalCache/Entry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace SplitIO\ThinSdk\Utils\EvalCache;

class Entry
{
private /*string*/ $treatment;
private /*string?*/ $config;
private /*bool*/ $hascfg;

public function __construct(string $treatment, bool $hasConfig = false, ?string $config = null)
{
$this->treatment = $treatment;
$this->config = $config;
$this->hascfg = $hasConfig;
}

public function getTreatment(): string
{
return $this->treatment;
}

public function getConfig(): ?string
{
return $this->config;
}

public function hasConfig(): bool
{
return $this->hascfg;
}
}
8 changes: 8 additions & 0 deletions src/Utils/EvalCache/InputHasher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace SplitIO\ThinSdk\Utils\EvalCache;

interface InputHasher
{
public function hashInput(string $key, string $feature, ?array $attributes = null): string;
}
16 changes: 16 additions & 0 deletions src/Utils/EvalCache/KeyAttributeCRC32Hasher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace SplitIO\ThinSdk\Utils\EvalCache;

class KeyAttributeCRC32Hasher implements InputHasher
{
public function hashInput(string $key, string $feature, ?array $attributes = null): string
{
// based on https://grechin.org/2021/04/06/php-json-encode-vs-serialize-performance-comparison.html
// php `serialize` is slower for encoding but faster for decoding.
// Since this we never decode here (we're just serializing for hashing purposes),
// it makes sense to go with JSON
$prefix = $key . "::" . $feature;
return is_null($attributes) ? $prefix : $prefix . "::" . crc32(json_encode($attributes));
}
}
11 changes: 11 additions & 0 deletions src/Utils/EvalCache/KeyOnlyHasher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace SplitIO\ThinSdk\Utils\EvalCache;

class KeyOnlyHasher implements InputHasher
{
public function hashInput(string $key, string $feature, ?array $attributes = null): string
{
return $key . "::" . $feature;
}
}
Loading

0 comments on commit 0129531

Please sign in to comment.