using EntitlementBLL.Common; using EntitlementBLL.Exceptions; using EntitlementBLL.Interfaces; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.DaprCache; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Implementations; /// /// Every mutating method here appends to LFEATUREFLAGAUDIT and evicts the "ent:flags" cache key /// (30s TTL, per the plan's OL-1 default) BEFORE returning — the kill-switch path in particular /// must guarantee IsFeatureEnabledAsync sees the change immediately on the very next call. /// public class FeatureFlagService : IFeatureFlagService { private const string FlagsCacheKey = "ent:flags"; private readonly IFeatureFlagDAL _FlagDAL; private readonly IAuditDAL _AuditDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly KeyInvalidate _KeyInvalidate; private readonly IEntitlementLoginFactory _LoginFactory; public FeatureFlagService( IFeatureFlagDAL flagDAL, IAuditDAL auditDAL, AutoNumber autoNumber, IQueryExecutor queryExecutor, KeyInvalidate keyInvalidate, IEntitlementLoginFactory loginFactory) { _FlagDAL = flagDAL; _AuditDAL = auditDAL; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _KeyInvalidate = keyInvalidate; _LoginFactory = loginFactory; } public async Task ActivateKillSwitchAsync(int featureFlagId, string reason, int activatedByUserId, CancellationToken ct) { var login = _LoginFactory.Create(-1, activatedByUserId); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-kill-switch", new { featureFlagId, reason }); var flag = await _FlagDAL.GetByIdAsync(featureFlagId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"FeatureFlag {featureFlagId} not found."); var oldStatus = flag.FlagStatus; GB5Trace.Step("save-kill-switch", new { featureFlagId }); await _FlagDAL.UpdateFlagStatusAsync(featureFlagId, (byte)FlagStatusEnum.KillSwitch, reason, login, tx, ct).ConfigureAwait(false); await _AuditDAL.InsertFeatureFlagAuditAsync(new FeatureFlagAuditDTO { FeatureFlagId = featureFlagId, FeatureId = flag.FeatureId, Action = "KILL_SWITCH_ACTIVATED", OldFlagStatus = oldStatus, NewFlagStatus = (byte)FlagStatusEnum.KillSwitch, KillSwitchReason = reason, CreatedById = activatedByUserId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); // Evict synchronously BEFORE returning — the caller must see the kill switch take // effect on the very next IsFeatureEnabledAsync call, no cache staleness window. await _KeyInvalidate.InvalidateCache(FlagsCacheKey).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("activate-kill-switch-failed", ex); throw; } } public async Task LiftKillSwitchAsync(int featureFlagId, string reason, int userId, CancellationToken ct) { var login = _LoginFactory.Create(-1, userId); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-lift-kill-switch", new { featureFlagId, reason }); var flag = await _FlagDAL.GetByIdAsync(featureFlagId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"FeatureFlag {featureFlagId} not found."); var oldStatus = flag.FlagStatus; GB5Trace.Step("save-lift-kill-switch", new { featureFlagId }); await _FlagDAL.UpdateFlagStatusAsync(featureFlagId, (byte)FlagStatusEnum.Off, null, login, tx, ct).ConfigureAwait(false); await _AuditDAL.InsertFeatureFlagAuditAsync(new FeatureFlagAuditDTO { FeatureFlagId = featureFlagId, FeatureId = flag.FeatureId, Action = "KILL_SWITCH_LIFTED", OldFlagStatus = oldStatus, NewFlagStatus = (byte)FlagStatusEnum.Off, KillSwitchReason = reason, CreatedById = userId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); await _KeyInvalidate.InvalidateCache(FlagsCacheKey).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("lift-kill-switch-failed", ex); throw; } } public async Task UpdateRolloutAsync(int featureFlagId, RolloutTypeEnum rollout, int pct, CancellationToken ct) { if (pct is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(pct), "Rollout percent must be 0-100."); var login = _LoginFactory.Create(-1); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-update-rollout", new { featureFlagId, rollout, pct }); var flag = await _FlagDAL.GetByIdAsync(featureFlagId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"FeatureFlag {featureFlagId} not found."); var oldRollout = flag.RolloutType; GB5Trace.Step("save-update-rollout", new { featureFlagId }); await _FlagDAL.UpdateRolloutAsync(featureFlagId, (byte)rollout, (byte)pct, login, tx, ct).ConfigureAwait(false); await _AuditDAL.InsertFeatureFlagAuditAsync(new FeatureFlagAuditDTO { FeatureFlagId = featureFlagId, FeatureId = flag.FeatureId, Action = "ROLLOUT_CHANGED", OldRolloutType = oldRollout, NewRolloutType = (byte)rollout, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); await _KeyInvalidate.InvalidateCache(FlagsCacheKey).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("update-rollout-failed", ex); throw; } } public async Task AddTargetAsync(int featureFlagId, FlagTargetDto dto, CancellationToken ct) { if (dto is null) throw new ArgumentNullException(nameof(dto)); var login = _LoginFactory.Create(dto.TargetClientId > 0 ? dto.TargetClientId : -1); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-add-target", new { featureFlagId, dto.TargetType }); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTFEATUREFLAGTARGET, login).ConfigureAwait(false); var target = new FeatureFlagTargetDTO { FlagTargetId = auto.StartNumber, FeatureFlagId = featureFlagId, TargetType = dto.TargetType, TargetClientId = dto.TargetClientId, TargetUserId = dto.TargetUserId, IsEnabled = (byte)(dto.IsEnabled ? 1 : 0), Remarks = dto.Remarks }; GB5Trace.Step("save-add-target", new { featureFlagId, target.FlagTargetId }); await _FlagDAL.SaveTargetAsync(target, login, tx, ct).ConfigureAwait(false); var flag = await _FlagDAL.GetByIdAsync(featureFlagId, login, ct).ConfigureAwait(false); await _AuditDAL.InsertFeatureFlagAuditAsync(new FeatureFlagAuditDTO { FeatureFlagId = featureFlagId, FeatureId = flag?.FeatureId ?? -1, Action = "TARGET_ADDED", CreatedById = login.UserId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); await _KeyInvalidate.InvalidateCache(FlagsCacheKey).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("add-target-failed", ex); throw; } } public async Task RemoveTargetAsync(int flagTargetId, CancellationToken ct) { var login = _LoginFactory.Create(-1); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-remove-target", new { flagTargetId }); var target = await _FlagDAL.GetTargetByIdAsync(flagTargetId, login, ct).ConfigureAwait(false); GB5Trace.Step("save-remove-target", new { flagTargetId }); await _FlagDAL.DeleteTargetAsync(flagTargetId, login, tx, ct).ConfigureAwait(false); if (target is not null) { var flag = await _FlagDAL.GetByIdAsync(target.FeatureFlagId, login, ct).ConfigureAwait(false); await _AuditDAL.InsertFeatureFlagAuditAsync(new FeatureFlagAuditDTO { FeatureFlagId = target.FeatureFlagId, FeatureId = flag?.FeatureId ?? -1, Action = "TARGET_REMOVED", CreatedById = login.UserId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); } await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); await _KeyInvalidate.InvalidateCache(FlagsCacheKey).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("remove-target-failed", ex); throw; } } }