using Dapper; using Kbx.Shared.Experiments.Generated; using Npgsql; namespace Kbx.Shared.Experiments; public sealed class KbxExperimentAssignmentService(NpgsqlDataSource dataSource) { public async Task EvaluateAsync(Guid tenantId,Guid userId,string screenId,CancellationToken ct) { var definitions=KbxExperimentCatalog.Experiments.Values.Where(x=>x.ScreenId==screenId).ToArray(); var items=new List(); await using var connection=await dataSource.OpenConnectionAsync(ct); foreach(var definition in definitions) { var runtime=await connection.QuerySingleOrDefaultAsync(new CommandDefinition(""" select state State,rollout_percent RolloutPercent,kill_switch KillSwitch from kbx.experiment_runtime where tenant_id=@TenantId and experiment_id=@ExperimentId; """,new{TenantId=tenantId,ExperimentId=definition.Id},cancellationToken:ct)); var state=runtime?.State ?? definition.State; var rollout=runtime?.RolloutPercent ?? definition.RolloutPercent; var killed=runtime?.KillSwitch ?? false; // Regular users only receive actively enrolled assignments. Draft/paused/rolled-back definitions stay internal. if(killed||state!="running"||rollout<=0)continue; // Rollout percentage remains authoritative even for previously assigned users. // Assignment is preserved for reproducibility and becomes active again if rollout is raised later. if(!KbxExperimentAssignmentPolicy.IsEnrolled(tenantId,userId,definition.Id,rollout))continue; var existing=await connection.QuerySingleOrDefaultAsync(new CommandDefinition(""" select variant from kbx.experiment_assignments where tenant_id=@TenantId and experiment_id=@ExperimentId and user_id=@UserId; """,new{TenantId=tenantId,ExperimentId=definition.Id,UserId=userId},cancellationToken:ct)); if(existing is not null){items.Add(new(definition.Id,definition.FlagId,screenId,true,existing,state));continue;} var variant=KbxExperimentAssignmentPolicy.ChooseVariant(tenantId,userId,definition); await connection.ExecuteAsync(new CommandDefinition(""" insert into kbx.experiment_assignments(tenant_id,experiment_id,user_id,variant) values(@TenantId,@ExperimentId,@UserId,@Variant) on conflict do nothing; """,new{TenantId=tenantId,ExperimentId=definition.Id,UserId=userId,Variant=variant},cancellationToken:ct)); existing=await connection.QuerySingleAsync(new CommandDefinition(""" select variant from kbx.experiment_assignments where tenant_id=@TenantId and experiment_id=@ExperimentId and user_id=@UserId; """,new{TenantId=tenantId,ExperimentId=definition.Id,UserId=userId},cancellationToken:ct)); items.Add(new(definition.Id,definition.FlagId,screenId,true,existing,state)); } return new(items); } private sealed record RuntimeRow(string State,int RolloutPercent,bool KillSwitch); }