Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ vendor
tests/data
.phpunit.result.cache
.vscode
cache
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ test-data:
mkdir -p $(tempDir)
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
cp -r ${gitDataDir}ufc ${testDataDir}
mkdir -p ${testDataDir}/configuration-wire
cp -r ${gitDataDir}configuration-wire/*.json ${testDataDir}/configuration-wire/
rm -rf ${tempDir}

.PHONY: test
Expand Down
2 changes: 1 addition & 1 deletion src/API/APIResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class APIResource
public function __construct(
public readonly ?string $body,
public readonly bool $isModified,
public readonly ?string $ETag
public readonly ?string $eTag
) {
}
}
104 changes: 104 additions & 0 deletions src/Config/Configuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace Eppo\Config;

use Eppo\DTO\Bandit\Bandit;
use Eppo\DTO\BanditParametersResponse;
use Eppo\DTO\BanditReference;
use Eppo\DTO\ConfigurationWire\ConfigResponse;
use Eppo\DTO\ConfigurationWire\ConfigurationWire;
use Eppo\DTO\Flag;
use Eppo\DTO\FlagConfigResponse;

class Configuration
{
private array $parsedFlags = [];
private readonly FlagConfigResponse $flags;
private readonly BanditParametersResponse $bandits;


private function __construct(
private readonly ConfigResponse $flagsConfig,
private readonly ?ConfigResponse $banditsConfig
) {
$flagJson = json_decode($this->flagsConfig->response, true);
$banditsJson = json_decode($this->banditsConfig?->response ?? "", true);
$this->flags = FlagConfigResponse::fromJson($flagJson ?? []);
$this->bandits = BanditParametersResponse::fromJson($banditsJson ?? []);
}

public static function fromUfcResponses(ConfigResponse $flagsConfig, ?ConfigResponse $banditsConfig): Configuration
{
return new self($flagsConfig, $banditsConfig);
}

public static function fromConfigurationWire(ConfigurationWire $configurationWire): self
{
return new self($configurationWire?->config ?? null, $configurationWire?->bandits ?? null);
}

public static function fromFlags(array $flags, ?array $bandits = null)
{
$fcr = FlagConfigResponse::fromJson(["flags" => $flags]);
$flagsConfig = new ConfigResponse(response: json_encode($fcr));
$banditsConfig = $bandits ? new ConfigResponse(
response: json_encode(BanditParametersResponse::fromJson(["bandits" => $bandits]))
) : null;
return new self($flagsConfig, $banditsConfig);
}

public static function emptyConfig(): self
{
return self::fromFlags([]);
}

public function getFlag(string $key): ?Flag
{
if (!isset($this->parsedFlags[$key])) {
$flagObj = $this->flags->flags[$key] ?? null;
if ($flagObj !== null) {
$this->parsedFlags[$key] = Flag::fromJson($flagObj);
}
}
return $this->parsedFlags[$key] ?? null;
}

public function getBandit(string $banditKey): ?Bandit
{
if (!isset($this->bandits->bandits[$banditKey])) {
return null;
}
return Bandit::fromJson($this->bandits?->bandits[$banditKey]) ?? null;
}

public function getBanditByVariation(string $flagKey, string $variation): ?string
{
foreach ($this->flags->banditReferences as $banditKey => $banditReferenceObj) {
$banditReference = BanditReference::fromJson($banditReferenceObj);
foreach ($banditReference->flagVariations as $flagVariation) {
if ($flagVariation->flagKey === $flagKey && $flagVariation->variationKey === $variation) {
return $banditKey;
}
}
}
return null;
}

public function toConfigurationWire(): ConfigurationWire
{
return ConfigurationWire::fromResponses(
flags: $this->flagsConfig,
bandits: $this->banditsConfig
);
}

public function getFetchedAt(): ?string
{
return $this?->flagsConfig?->fetchedAt ?? null;
}

public function getFlagETag(): ?string
{
return $this->flagsConfig?->eTag ?? null;
}
}
172 changes: 34 additions & 138 deletions src/Config/ConfigurationLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,195 +3,91 @@
namespace Eppo\Config;

use Eppo\API\APIRequestWrapper;
use Eppo\Bandits\BanditReferenceIndexer;
use Eppo\Bandits\IBanditReferenceIndexer;
use Eppo\Bandits\IBandits;
use Eppo\DTO\Bandit\Bandit;
use Eppo\DTO\BanditReference;
use Eppo\DTO\Flag;
use Eppo\DTO\ConfigurationWire\ConfigResponse;
use Eppo\DTO\FlagConfigResponse;
use Eppo\Exception\HttpRequestException;
use Eppo\Exception\InvalidApiKeyException;
use Eppo\Exception\InvalidConfigurationException;
use Eppo\Flags\IFlags;
use Eppo\UFCParser;

class ConfigurationLoader implements IFlags, IBandits
class ConfigurationLoader
{
private const KEY_BANDIT_TIMESTAMP = "banditTimestamp";
private const KEY_LOADED_BANDIT_VERSIONS = 'banditModelVersions';
private UFCParser $parser;

private const KEY_FLAG_TIMESTAMP = "flagTimestamp";
private const KEY_FLAG_ETAG = "flagETag";

public function __construct(
private readonly APIRequestWrapper $apiRequestWrapper,
private readonly IConfigurationStore $configurationStore,
private readonly int $cacheAgeLimitMillis = 30 * 1000,
private readonly bool $optimizedBanditLoading = false
public readonly ConfigurationStore $configurationStore,
private readonly int $cacheAgeLimitMillis = 30 * 1000
) {
$this->parser = new UFCParser();
}

/**
* @throws InvalidApiKeyException
* @throws HttpRequestException
* @throws InvalidConfigurationException
*/
public function getFlag(string $key): ?Flag
{
$this->reloadConfigurationIfExpired();
return $this->configurationStore->getFlag($key);
}

/**
* @param string $flagKey
* @param string $variation
* @return string|null
* @throws HttpRequestException
* @throws InvalidApiKeyException
* @throws InvalidConfigurationException
*/
public function getBanditByVariation(string $flagKey, string $variation): ?string
{
$this->reloadConfigurationIfExpired();
return $this->configurationStore->getBanditReferenceIndexer()->getBanditByVariation($flagKey, $variation);
}

/**
* @throws HttpRequestException
* @throws InvalidApiKeyException
* @throws InvalidConfigurationException
*/
public function reloadConfigurationIfExpired(): void
{
$flagCacheAge = $this->getCacheAgeInMillis();
if ($flagCacheAge < 0 || $flagCacheAge >= ($this->cacheAgeLimitMillis)) {
if ($flagCacheAge >= ($this->cacheAgeLimitMillis)) {
$this->reloadConfiguration();
}
}

/**
* @throws HttpRequestException
* @throws InvalidApiKeyException
* @throws InvalidConfigurationException
*/
public function fetchAndStoreConfigurations(?string $flagETag): void
public function fetchAndStoreConfiguration(?string $flagETag): void
{
$response = $this->apiRequestWrapper->getUFC($flagETag);
if ($response->isModified) {
// Decode and set the data.
$configResponse = new ConfigResponse(
$response->body,
date('c'),
$response->eTag
);
$responseData = json_decode($response->body, true);
if (!$responseData) {

if ($responseData === null) {
syslog(LOG_WARNING, "[Eppo SDK] Empty or invalid response from the configuration server.");
return;
}

$inflated = array_map(fn($object) => $this->parser->parseFlag($object), $responseData['flags']);

// Create a handy helper class from the `banditReferences` to help connect flags to bandits.
if (isset($responseData['banditReferences'])) {
$banditReferences = array_map(
function ($json) {
return BanditReference::fromJson($json);
},
$responseData['banditReferences']
);
$indexer = BanditReferenceIndexer::from($banditReferences);
} else {
syslog(LOG_WARNING, "[EPPO SDK] No bandit-flag variations found in UFC response.");
$indexer = BanditReferenceIndexer::empty();
$fcr = FlagConfigResponse::fromJson($responseData);
$banditResponse = null;
// TODO: Also check current bandit models loaded for optimized bandit loading.
if (count($fcr->banditReferences) > 0) {
$bandits = $this->apiRequestWrapper->getBandits();
$banditResponse = new ConfigResponse($bandits->body, date('c'), $bandits->eTag);
}

$this->configurationStore->setUnifiedFlagConfiguration($inflated, $indexer);

// Only load bandits if there are any referenced by the flags.
if ($indexer->hasBandits()) {
$this->fetchBanditsAsRequired($indexer);
}

// Store metadata for next time.
$this->configurationStore->setMetadata(self::KEY_FLAG_TIMESTAMP, $this->millitime());
$this->configurationStore->setMetadata(self::KEY_FLAG_ETAG, $response->ETag);
$configuration = Configuration::fromUfcResponses($configResponse, $banditResponse);
$this->configurationStore->setConfiguration($configuration);
}
}

private function getCacheAgeInMillis(): int
{
$timestamp = $this->configurationStore->getMetadata(self::KEY_FLAG_TIMESTAMP);
if ($timestamp != null) {
return $this->millitime() - $timestamp;
}
return -1;
}

public function getBanditReferenceIndexer(): IBanditReferenceIndexer
{
return $this->configurationStore->getBanditReferenceIndexer();
}

/**
* @throws HttpRequestException
* @throws InvalidApiKeyException
* @throws InvalidConfigurationException
*/
private function fetchAndStoreBandits(): void
{
$banditModelResponse = json_decode($this->apiRequestWrapper->getBandits()->body, true);
if (!$banditModelResponse || !isset($banditModelResponse['bandits'])) {
syslog(LOG_WARNING, "[Eppo SDK] Empty or invalid response from the configuration server.");
$bandits = [];
} else {
$bandits = array_map(fn($json) => Bandit::fromJson($json), $banditModelResponse['bandits']);
$timestamp = $this->configurationStore->getConfiguration()->getFetchedAt();
if (!$timestamp) {
return PHP_INT_MAX;
}
$banditModelVersions = array_map(fn($bandit) => $bandit->modelVersion, $bandits);

$this->configurationStore->setBandits($bandits);
$this->configurationStore->setMetadata(self::KEY_LOADED_BANDIT_VERSIONS, $banditModelVersions);
$this->configurationStore->setMetadata(self::KEY_BANDIT_TIMESTAMP, time());
}

public function getBandit(string $banditKey): ?Bandit
{
return $this->configurationStore->getBandit($banditKey);
}

/**
* Loads bandits unless `optimizedBanditLoading` is `true` in which case, currently loaded bandit models are
* compared to those required by flags to determine whether to (re)load bandit models.
*
* @param IBanditReferenceIndexer $indexer
* @return void
* @throws HttpRequestException
* @throws InvalidApiKeyException
* @throws InvalidConfigurationException
*/
private function fetchBanditsAsRequired(IBanditReferenceIndexer $indexer): void
{
// Get the currently loaded bandits to determine if they satisfy what's required by the flags
$currentlyLoadedBanditModels = $this->configurationStore->getMetadata(
self::KEY_LOADED_BANDIT_VERSIONS
) ?? [];
$references = $indexer->getBanditModelKeys();

if (array_diff($references, $currentlyLoadedBanditModels)) {
$this->fetchAndStoreBandits();
try {
$dateTime = new \DateTime($timestamp);
$timestampMillis = (int)($dateTime->format('U.u') * 1000);
return $this->milliTime() - $timestampMillis;
} catch (\Exception $e) {
return PHP_INT_MAX;
}
}

/**
* @return void
* @throws HttpRequestException
* @throws InvalidApiKeyException
* @throws InvalidConfigurationException
*/
public function reloadConfiguration(): void
{
$flagETag = $this->configurationStore->getMetadata(self::KEY_FLAG_ETAG);
$this->fetchAndStoreConfigurations($flagETag);
$flagETag = $this->configurationStore->getConfiguration()->getFlagETag();
$this->fetchAndStoreConfiguration($flagETag);
}

private function millitime(): int
private function milliTime(): int
{
return intval(microtime(true) * 1000);
}
Expand Down
Loading