Skip to content

Commit 6df125f

Browse files
authored
fix: Store configuration atomically (#48)
* Config wire types and traits * Configuration Class * move UFCParser logic to Flag * refactor reloadIfExpired out of getFlag * Tests for Configuration class * new config store * don't evaluate bandit for default value * log cache errors
1 parent 2f6dfc7 commit 6df125f

29 files changed

+1192
-980
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ vendor
33
tests/data
44
.phpunit.result.cache
55
.vscode
6+
cache

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ test-data:
3838
mkdir -p $(tempDir)
3939
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
4040
cp -r ${gitDataDir}ufc ${testDataDir}
41+
mkdir -p ${testDataDir}/configuration-wire
42+
cp -r ${gitDataDir}configuration-wire/*.json ${testDataDir}/configuration-wire/
4143
rm -rf ${tempDir}
4244

4345
.PHONY: test

src/API/APIResource.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class APIResource
77
public function __construct(
88
public readonly ?string $body,
99
public readonly bool $isModified,
10-
public readonly ?string $ETag
10+
public readonly ?string $eTag
1111
) {
1212
}
1313
}

src/Config/Configuration.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace Eppo\Config;
4+
5+
use Eppo\DTO\Bandit\Bandit;
6+
use Eppo\DTO\BanditParametersResponse;
7+
use Eppo\DTO\BanditReference;
8+
use Eppo\DTO\ConfigurationWire\ConfigResponse;
9+
use Eppo\DTO\ConfigurationWire\ConfigurationWire;
10+
use Eppo\DTO\Flag;
11+
use Eppo\DTO\FlagConfigResponse;
12+
13+
class Configuration
14+
{
15+
private array $parsedFlags = [];
16+
private readonly FlagConfigResponse $flags;
17+
private readonly BanditParametersResponse $bandits;
18+
19+
20+
private function __construct(
21+
private readonly ConfigResponse $flagsConfig,
22+
private readonly ?ConfigResponse $banditsConfig
23+
) {
24+
$flagJson = json_decode($this->flagsConfig->response, true);
25+
$banditsJson = json_decode($this->banditsConfig?->response ?? "", true);
26+
$this->flags = FlagConfigResponse::fromJson($flagJson ?? []);
27+
$this->bandits = BanditParametersResponse::fromJson($banditsJson ?? []);
28+
}
29+
30+
public static function fromUfcResponses(ConfigResponse $flagsConfig, ?ConfigResponse $banditsConfig): Configuration
31+
{
32+
return new self($flagsConfig, $banditsConfig);
33+
}
34+
35+
public static function fromConfigurationWire(ConfigurationWire $configurationWire): self
36+
{
37+
return new self($configurationWire?->config ?? null, $configurationWire?->bandits ?? null);
38+
}
39+
40+
public static function fromFlags(array $flags, ?array $bandits = null)
41+
{
42+
$fcr = FlagConfigResponse::fromJson(["flags" => $flags]);
43+
$flagsConfig = new ConfigResponse(response: json_encode($fcr));
44+
$banditsConfig = $bandits ? new ConfigResponse(
45+
response: json_encode(BanditParametersResponse::fromJson(["bandits" => $bandits]))
46+
) : null;
47+
return new self($flagsConfig, $banditsConfig);
48+
}
49+
50+
public static function emptyConfig(): self
51+
{
52+
return self::fromFlags([]);
53+
}
54+
55+
public function getFlag(string $key): ?Flag
56+
{
57+
if (!isset($this->parsedFlags[$key])) {
58+
$flagObj = $this->flags->flags[$key] ?? null;
59+
if ($flagObj !== null) {
60+
$this->parsedFlags[$key] = Flag::fromJson($flagObj);
61+
}
62+
}
63+
return $this->parsedFlags[$key] ?? null;
64+
}
65+
66+
public function getBandit(string $banditKey): ?Bandit
67+
{
68+
if (!isset($this->bandits->bandits[$banditKey])) {
69+
return null;
70+
}
71+
return Bandit::fromJson($this->bandits?->bandits[$banditKey]) ?? null;
72+
}
73+
74+
public function getBanditByVariation(string $flagKey, string $variation): ?string
75+
{
76+
foreach ($this->flags->banditReferences as $banditKey => $banditReferenceObj) {
77+
$banditReference = BanditReference::fromJson($banditReferenceObj);
78+
foreach ($banditReference->flagVariations as $flagVariation) {
79+
if ($flagVariation->flagKey === $flagKey && $flagVariation->variationKey === $variation) {
80+
return $banditKey;
81+
}
82+
}
83+
}
84+
return null;
85+
}
86+
87+
public function toConfigurationWire(): ConfigurationWire
88+
{
89+
return ConfigurationWire::fromResponses(
90+
flags: $this->flagsConfig,
91+
bandits: $this->banditsConfig
92+
);
93+
}
94+
95+
public function getFetchedAt(): ?string
96+
{
97+
return $this?->flagsConfig?->fetchedAt ?? null;
98+
}
99+
100+
public function getFlagETag(): ?string
101+
{
102+
return $this->flagsConfig?->eTag ?? null;
103+
}
104+
}

src/Config/ConfigurationLoader.php

Lines changed: 34 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -3,195 +3,91 @@
33
namespace Eppo\Config;
44

55
use Eppo\API\APIRequestWrapper;
6-
use Eppo\Bandits\BanditReferenceIndexer;
7-
use Eppo\Bandits\IBanditReferenceIndexer;
8-
use Eppo\Bandits\IBandits;
9-
use Eppo\DTO\Bandit\Bandit;
10-
use Eppo\DTO\BanditReference;
11-
use Eppo\DTO\Flag;
6+
use Eppo\DTO\ConfigurationWire\ConfigResponse;
7+
use Eppo\DTO\FlagConfigResponse;
128
use Eppo\Exception\HttpRequestException;
139
use Eppo\Exception\InvalidApiKeyException;
14-
use Eppo\Exception\InvalidConfigurationException;
15-
use Eppo\Flags\IFlags;
16-
use Eppo\UFCParser;
1710

18-
class ConfigurationLoader implements IFlags, IBandits
11+
class ConfigurationLoader
1912
{
20-
private const KEY_BANDIT_TIMESTAMP = "banditTimestamp";
21-
private const KEY_LOADED_BANDIT_VERSIONS = 'banditModelVersions';
22-
private UFCParser $parser;
23-
24-
private const KEY_FLAG_TIMESTAMP = "flagTimestamp";
25-
private const KEY_FLAG_ETAG = "flagETag";
26-
2713
public function __construct(
2814
private readonly APIRequestWrapper $apiRequestWrapper,
29-
private readonly IConfigurationStore $configurationStore,
30-
private readonly int $cacheAgeLimitMillis = 30 * 1000,
31-
private readonly bool $optimizedBanditLoading = false
15+
public readonly ConfigurationStore $configurationStore,
16+
private readonly int $cacheAgeLimitMillis = 30 * 1000
3217
) {
33-
$this->parser = new UFCParser();
34-
}
35-
36-
/**
37-
* @throws InvalidApiKeyException
38-
* @throws HttpRequestException
39-
* @throws InvalidConfigurationException
40-
*/
41-
public function getFlag(string $key): ?Flag
42-
{
43-
$this->reloadConfigurationIfExpired();
44-
return $this->configurationStore->getFlag($key);
4518
}
4619

4720
/**
48-
* @param string $flagKey
49-
* @param string $variation
50-
* @return string|null
5121
* @throws HttpRequestException
5222
* @throws InvalidApiKeyException
53-
* @throws InvalidConfigurationException
54-
*/
55-
public function getBanditByVariation(string $flagKey, string $variation): ?string
56-
{
57-
$this->reloadConfigurationIfExpired();
58-
return $this->configurationStore->getBanditReferenceIndexer()->getBanditByVariation($flagKey, $variation);
59-
}
60-
61-
/**
62-
* @throws HttpRequestException
63-
* @throws InvalidApiKeyException
64-
* @throws InvalidConfigurationException
6523
*/
6624
public function reloadConfigurationIfExpired(): void
6725
{
6826
$flagCacheAge = $this->getCacheAgeInMillis();
69-
if ($flagCacheAge < 0 || $flagCacheAge >= ($this->cacheAgeLimitMillis)) {
27+
if ($flagCacheAge >= ($this->cacheAgeLimitMillis)) {
7028
$this->reloadConfiguration();
7129
}
7230
}
7331

7432
/**
7533
* @throws HttpRequestException
7634
* @throws InvalidApiKeyException
77-
* @throws InvalidConfigurationException
7835
*/
79-
public function fetchAndStoreConfigurations(?string $flagETag): void
36+
public function fetchAndStoreConfiguration(?string $flagETag): void
8037
{
8138
$response = $this->apiRequestWrapper->getUFC($flagETag);
8239
if ($response->isModified) {
83-
// Decode and set the data.
40+
$configResponse = new ConfigResponse(
41+
$response->body,
42+
date('c'),
43+
$response->eTag
44+
);
8445
$responseData = json_decode($response->body, true);
85-
if (!$responseData) {
46+
47+
if ($responseData === null) {
8648
syslog(LOG_WARNING, "[Eppo SDK] Empty or invalid response from the configuration server.");
8749
return;
8850
}
89-
90-
$inflated = array_map(fn($object) => $this->parser->parseFlag($object), $responseData['flags']);
91-
92-
// Create a handy helper class from the `banditReferences` to help connect flags to bandits.
93-
if (isset($responseData['banditReferences'])) {
94-
$banditReferences = array_map(
95-
function ($json) {
96-
return BanditReference::fromJson($json);
97-
},
98-
$responseData['banditReferences']
99-
);
100-
$indexer = BanditReferenceIndexer::from($banditReferences);
101-
} else {
102-
syslog(LOG_WARNING, "[EPPO SDK] No bandit-flag variations found in UFC response.");
103-
$indexer = BanditReferenceIndexer::empty();
51+
$fcr = FlagConfigResponse::fromJson($responseData);
52+
$banditResponse = null;
53+
// TODO: Also check current bandit models loaded for optimized bandit loading.
54+
if (count($fcr->banditReferences) > 0) {
55+
$bandits = $this->apiRequestWrapper->getBandits();
56+
$banditResponse = new ConfigResponse($bandits->body, date('c'), $bandits->eTag);
10457
}
10558

106-
$this->configurationStore->setUnifiedFlagConfiguration($inflated, $indexer);
107-
108-
// Only load bandits if there are any referenced by the flags.
109-
if ($indexer->hasBandits()) {
110-
$this->fetchBanditsAsRequired($indexer);
111-
}
112-
113-
// Store metadata for next time.
114-
$this->configurationStore->setMetadata(self::KEY_FLAG_TIMESTAMP, $this->millitime());
115-
$this->configurationStore->setMetadata(self::KEY_FLAG_ETAG, $response->ETag);
59+
$configuration = Configuration::fromUfcResponses($configResponse, $banditResponse);
60+
$this->configurationStore->setConfiguration($configuration);
11661
}
11762
}
11863

11964
private function getCacheAgeInMillis(): int
12065
{
121-
$timestamp = $this->configurationStore->getMetadata(self::KEY_FLAG_TIMESTAMP);
122-
if ($timestamp != null) {
123-
return $this->millitime() - $timestamp;
124-
}
125-
return -1;
126-
}
127-
128-
public function getBanditReferenceIndexer(): IBanditReferenceIndexer
129-
{
130-
return $this->configurationStore->getBanditReferenceIndexer();
131-
}
132-
133-
/**
134-
* @throws HttpRequestException
135-
* @throws InvalidApiKeyException
136-
* @throws InvalidConfigurationException
137-
*/
138-
private function fetchAndStoreBandits(): void
139-
{
140-
$banditModelResponse = json_decode($this->apiRequestWrapper->getBandits()->body, true);
141-
if (!$banditModelResponse || !isset($banditModelResponse['bandits'])) {
142-
syslog(LOG_WARNING, "[Eppo SDK] Empty or invalid response from the configuration server.");
143-
$bandits = [];
144-
} else {
145-
$bandits = array_map(fn($json) => Bandit::fromJson($json), $banditModelResponse['bandits']);
66+
$timestamp = $this->configurationStore->getConfiguration()->getFetchedAt();
67+
if (!$timestamp) {
68+
return PHP_INT_MAX;
14669
}
147-
$banditModelVersions = array_map(fn($bandit) => $bandit->modelVersion, $bandits);
148-
149-
$this->configurationStore->setBandits($bandits);
150-
$this->configurationStore->setMetadata(self::KEY_LOADED_BANDIT_VERSIONS, $banditModelVersions);
151-
$this->configurationStore->setMetadata(self::KEY_BANDIT_TIMESTAMP, time());
152-
}
153-
154-
public function getBandit(string $banditKey): ?Bandit
155-
{
156-
return $this->configurationStore->getBandit($banditKey);
157-
}
158-
159-
/**
160-
* Loads bandits unless `optimizedBanditLoading` is `true` in which case, currently loaded bandit models are
161-
* compared to those required by flags to determine whether to (re)load bandit models.
162-
*
163-
* @param IBanditReferenceIndexer $indexer
164-
* @return void
165-
* @throws HttpRequestException
166-
* @throws InvalidApiKeyException
167-
* @throws InvalidConfigurationException
168-
*/
169-
private function fetchBanditsAsRequired(IBanditReferenceIndexer $indexer): void
170-
{
171-
// Get the currently loaded bandits to determine if they satisfy what's required by the flags
172-
$currentlyLoadedBanditModels = $this->configurationStore->getMetadata(
173-
self::KEY_LOADED_BANDIT_VERSIONS
174-
) ?? [];
175-
$references = $indexer->getBanditModelKeys();
176-
177-
if (array_diff($references, $currentlyLoadedBanditModels)) {
178-
$this->fetchAndStoreBandits();
70+
try {
71+
$dateTime = new \DateTime($timestamp);
72+
$timestampMillis = (int)($dateTime->format('U.u') * 1000);
73+
return $this->milliTime() - $timestampMillis;
74+
} catch (\Exception $e) {
75+
return PHP_INT_MAX;
17976
}
18077
}
18178

18279
/**
18380
* @return void
18481
* @throws HttpRequestException
18582
* @throws InvalidApiKeyException
186-
* @throws InvalidConfigurationException
18783
*/
18884
public function reloadConfiguration(): void
18985
{
190-
$flagETag = $this->configurationStore->getMetadata(self::KEY_FLAG_ETAG);
191-
$this->fetchAndStoreConfigurations($flagETag);
86+
$flagETag = $this->configurationStore->getConfiguration()->getFlagETag();
87+
$this->fetchAndStoreConfiguration($flagETag);
19288
}
19389

194-
private function millitime(): int
90+
private function milliTime(): int
19591
{
19692
return intval(microtime(true) * 1000);
19793
}

0 commit comments

Comments
 (0)