Skip to content

Commit

Permalink
Merge pull request #3 from fossapps/feature_allocation
Browse files Browse the repository at this point in the history
Feature allocation
  • Loading branch information
cyberhck authored Jul 11, 2020
2 parents 262acc2 + 0a409cf commit 1d6dc14
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 12 deletions.
31 changes: 29 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
dotnet-version: 3.1.100

- name: Build
run: dotnet build --configuration Release
run: dotnet build --configuration Release ./$SERVICE_NAME_CAPITALIZED.Api/$SERVICE_NAME_CAPITALIZED.Api.csproj

run-unit-tests:
runs-on: ubuntu-latest
Expand All @@ -32,7 +32,7 @@ jobs:
dotnet-version: 3.1.100

- name: Run Unit Tests
run: dotnet test
run: cd $SERVICE_NAME_CAPITALIZED.UnitTest && dotnet test

build-docker-image:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -73,6 +73,33 @@ jobs:
cd ./${SERVICE_NAME_CAPITALIZED}.UnitTest/ExternalTests
sh ./postman_tests.sh
test-sdk:
needs: build-docker-image
runs-on: ubuntu-latest

steps:
- name: Pull Docker Image
uses: actions/checkout@v1
with:
name: docker-pull

- name: 'Load Docker Image'
run: |
TAG=`git rev-parse --short=4 ${GITHUB_SHA}`
docker pull fossapps/$SERVICE_NAME:$TAG
- name: Spin-up Containers
run: |
TAG=`git rev-parse --short=4 ${GITHUB_SHA}`
TAG=$TAG docker-compose -f ./docker-compose.ci.yml up -d
- name: Generate and Test SDK
run: |
cd ./Sdk
sh ./generate_sdk.sh
cd ../
dotnet build
cd Sdk.Test
dotnet test
publish:
needs: [ build-docker-image, run-postman-tests ]
runs-on: ubuntu-latest
Expand Down
12 changes: 12 additions & 0 deletions Feature.Manager.sln
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Feature.Manager.Api", "Feat
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Feature.Manager.UnitTest", "Feature.Manager.UnitTest\Feature.Manager.UnitTest.csproj", "{4384EF12-1CE6-4672-BC61-2F20F83D2744}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk", "Sdk\Sdk.csproj", "{2D5EAAA2-AFF8-4DC6-8AD0-7D979C09EF6D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk.Test", "Sdk.Test\Sdk.Test.csproj", "{9ED47FD6-E09C-4A29-AD7A-F3DBCD112390}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -18,5 +22,13 @@ Global
{4384EF12-1CE6-4672-BC61-2F20F83D2744}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4384EF12-1CE6-4672-BC61-2F20F83D2744}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4384EF12-1CE6-4672-BC61-2F20F83D2744}.Release|Any CPU.Build.0 = Release|Any CPU
{2D5EAAA2-AFF8-4DC6-8AD0-7D979C09EF6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D5EAAA2-AFF8-4DC6-8AD0-7D979C09EF6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D5EAAA2-AFF8-4DC6-8AD0-7D979C09EF6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D5EAAA2-AFF8-4DC6-8AD0-7D979C09EF6D}.Release|Any CPU.Build.0 = Release|Any CPU
{9ED47FD6-E09C-4A29-AD7A-F3DBCD112390}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9ED47FD6-E09C-4A29-AD7A-F3DBCD112390}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9ED47FD6-E09C-4A29-AD7A-F3DBCD112390}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9ED47FD6-E09C-4A29-AD7A-F3DBCD112390}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
105 changes: 105 additions & 0 deletions Sdk.Test/FeatureClientTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Fossapps.FeatureManager;
using FossApps.FeatureManager.Models;
using Moq;
using NUnit.Framework;

namespace Sdk.Test
{
public class FeatureClientTest
{
private IFeatureWorker _featureWorker;
private IUserDataRepo _userDataRepo;
private static string GetUuid()
{
return Guid.NewGuid().ToString();
}

[SetUp]
public void Setup()
{
var runningFeatures = new List<RunningFeature>
{
new RunningFeature
{
Allocation = 100,
FeatureId = "APP-1",
FeatureToken = GetUuid(),
RunId = GetUuid(),
RunToken = GetUuid()
},
new RunningFeature
{
Allocation = 50,
FeatureId = "APP-2",
FeatureToken = GetUuid(),
RunId = GetUuid(),
RunToken = GetUuid()
}
};
var mockWorker = new Mock<IFeatureWorker>();
mockWorker.Setup(x => x.GetRunningFeatures()).Returns(runningFeatures);
var mockUserData = new Mock<IUserDataRepo>();
mockUserData.Setup(x => x.GetUserId()).Returns(GetUuid);
_userDataRepo = mockUserData.Object;
_featureWorker = mockWorker.Object;
}

[Test]
public void TestFeatureClientReturnsSameVariantForMultipleRuns()
{
var mockUserData = new Mock<IUserDataRepo>();
mockUserData.Setup(x => x.GetUserId()).Returns("a6e91dde-c35a-11ea-87d0-0242ac130003");
var client = new FeatureClient(_featureWorker, mockUserData.Object);
var firstVariant = client.GetVariant("APP-1");
for (var i = 0; i < 1000; i++)
{
Assert.AreEqual(firstVariant, client.GetVariant("APP-1"));
}
}

[Test]
public void TestFeatureClientReturnsRoughlyHalfVariationsForMultipleRunsForRandomUsers()
{
var dictionary = new Dictionary<char, int> {['A'] = 0, ['B'] = 0, ['X'] = 0, ['Z'] = 0};
var client = new FeatureClient(_featureWorker, _userDataRepo);
for (var i = 0; i <= 1000000; i++)
{
var variant = client.GetVariant("APP-1");
dictionary[variant] = dictionary[variant] + 1;
}

var total = dictionary['A'] + dictionary['B'];
var diff = dictionary['A'] - dictionary['B'];
var bias = ((float) diff / total) * 100;
Assert.AreEqual(0, dictionary['Z']);
Assert.AreEqual(0, dictionary['X']);
Assert.LessOrEqual(Math.Abs(bias), 0.5);
}

[Test]
public void TestFeatureWithAllocationHasLowAllocationBias()
{
var dictionary = new Dictionary<char, int> {['A'] = 0, ['B'] = 0, ['X'] = 0, ['Z'] = 0};
var client = new FeatureClient(_featureWorker, _userDataRepo);
for (var i = 0; i <= 1000000; i++)
{
var variant = client.GetVariant("APP-2");
dictionary[variant] = dictionary[variant] + 1;
}

var variantA = dictionary['A'];
var variantB = dictionary['B'];
var variantZ = dictionary['Z'];
Assert.AreEqual(0, dictionary['X']);
var total = variantA + variantB + variantZ;
var biasWithZ = variantA + variantB - variantZ;
var biasWithZPerc = ((float) biasWithZ / total) * 100;
var totalAllocated = variantA + variantB;
var biasPerc = ((float) (variantA - variantB) / totalAllocated) * 100;
Assert.LessOrEqual(biasPerc, 0.5);
Assert.LessOrEqual(biasWithZPerc, 0.5);
}
}
}
20 changes: 20 additions & 0 deletions Sdk.Test/Sdk.Test.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Moq" Version="4.14.5" />
<PackageReference Include="nunit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.15.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Sdk\Sdk.csproj" />
</ItemGroup>

</Project>
49 changes: 40 additions & 9 deletions Sdk/FeatureContainer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using FossApps.FeatureManager;
using FossApps.FeatureManager.Models;
Expand All @@ -9,7 +11,12 @@
namespace Fossapps.FeatureManager
{
// this is singleton
public class FeatureWorker
public interface IFeatureWorker
{
IEnumerable<RunningFeature> GetRunningFeatures();
void Init();
}
public class FeatureWorker : IFeatureWorker
{
private readonly TimeSpan _syncInterval;
private readonly FossApps.FeatureManager.FeatureManager _manager;
Expand Down Expand Up @@ -61,24 +68,48 @@ public interface IUserDataRepo
// this is scoped
public class FeatureClient
{
private readonly FeatureWorker _featureWorker;
private readonly IFeatureWorker _featureWorker;
private readonly IUserDataRepo _userDataRepo;

public FeatureClient(FeatureWorker featureWorker, IUserDataRepo userDataRepo)
public FeatureClient(IFeatureWorker featureWorker, IUserDataRepo userDataRepo)
{
_featureWorker = featureWorker;
_userDataRepo = userDataRepo;
}

public bool IsFeatureOn(string featId)
private RunningFeature GetFeatureById(string featId)
{
return _featureWorker.GetRunningFeatures().FirstOrDefault(x => x.FeatureId == featId);
}
public char GetVariant(string featId)
{
var feature = _featureWorker.GetRunningFeatures().FirstOrDefault(x => x.FeatureId == featId);
if (feature == null)
var feature = GetFeatureById(featId);

if (feature == null || !feature.Allocation.HasValue || string.IsNullOrEmpty(feature.RunToken) || string.IsNullOrEmpty(feature.FeatureToken))
{
return false;
return 'X';
}

return true;
if (feature.Allocation.Value == 100 || GetBucket(_userDataRepo.GetUserId(), feature.FeatureToken, 100) <= feature.Allocation)
{
var bucket = GetBucket(_userDataRepo.GetUserId(), feature.RunToken, 2);
return bucket switch
{
1 => 'A',
2 => 'B',
_ => 'X'
};
}
return 'Z';
}

private static int GetBucket(string userToken, string bucketToken, int numberOfBuckets)
{
var tokens = (userToken.Trim() + bucketToken.Trim()).Replace("-", "").ToLower();
using var md5 = MD5.Create();
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(tokens));
var number = BitConverter.ToUInt64(bytes);
return (int) (number % (ulong) numberOfBuckets) + 1;
}
}

Expand All @@ -89,7 +120,7 @@ public static void SetupFeatureClients<TUserDataImplementation>(this IServiceCol
var worker = new FeatureWorker(endpoint, syncInterval);
worker.Init();
collection.AddScoped<IUserDataRepo, TUserDataImplementation>();
collection.AddSingleton(worker);
collection.AddSingleton<IFeatureWorker>(worker);
collection.AddScoped<FeatureClient>();
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sdk/generate_sdk.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
mkdir -p Generated
docker run --net=host --rm -v $(pwd)/Generated:/app/sdk node bash -c "apt update && apt install libunwind-dev -y && npm i -g autorest && autorest --input-file=http://localhost:5000/swagger/v1/swagger.json --csharp --namespace=FossApps.FeatureManager --override-client-name=FeatureManager --output-folder=/app/sdk --clear-output-folder"
docker run --net=host --rm -v $(pwd)/Generated:/app/sdk node bash -c "apt update && apt install libunwind-dev -y && npm i -g autorest@3.0.6187 && autorest --input-file=http://localhost:5000/swagger/v1/swagger.json --csharp --namespace=FossApps.FeatureManager --override-client-name=FeatureManager --output-folder=/app/sdk --clear-output-folder"

0 comments on commit 1d6dc14

Please # to comment.