mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
fix(rules): sunset path is safe-state-first to protect tenant opt-outs
Codex review (Medium): even with the GC-first ordering from b855b2b9, a
race remained — after the orphan-GC deleted tenant RuleState rows but
BEFORE the global tombstone delete completed, a transient Azure failure
on the tombstone would leave the global rule alive while tenant overrides
were already gone. For a future default-ENABLED sunset rule this would
silently flip RuleState{Enabled=false} (explicit tenant opt-out) into
"now using the global default (enabled)" — exactly the regression class
the orphan-GC was meant to prevent.
Fix: a strict three-step sequence with mutual dependencies. Extracted
into a single ProcessSunsetRuleAsync helper so EnsureBuiltInRulesSeededAsync,
ReseedBuiltInRulesAsync, and ReseedFromGitHubFunction.ReseedAnalyzeAsync
all use the same primitive:
1. Safe-state: write the global rule with Enabled=false +
MarkSessionAsFailedDefault=false. Neutralises the default BEFORE
touching tenant state. If a default-enabled sunset rule's
RuleState{Enabled=false} (opt-out) survives every later step
failing, the override now matches the global default — same
effective result, no silent flip.
2. Orphan GC across tenants. Only attempted if safe-state succeeded.
3. Tombstone (DeleteAnalyzeRuleAsync global). Only attempted if GC
reported zero failures.
Returns a SunsetOutcome enum with three explicit failure values
(SkippedOnSafeStateFailure / SkippedOnGcFailure / SkippedOnGlobalDeleteFailure)
so callers and tests can pin which step bailed.
Additionally fixes the same-instance retry gap Codex called out:
EnsureBuiltInRulesSeededAsync only sets _seeded=true when EVERY sunset
rule reached Completed. Any partial outcome leaves the seed flag false
so the next GetAllRulesForTenantAsync call retries without waiting for
a process restart. The Function-App can recycle on its own schedule
(hours on a warm instance) and we don't want sunset cleanup to wait
that long.
Behaviour for the original CORR-001/APP-009 case: both shipped
Enabled=false globally and the tenant overrides were Enabled=true
(opt-IN). Safe-state is a no-op (already Enabled=false), GC removes
the opt-ins, tombstone drops the global row. Same end state as before,
just retry-safe across transient Azure errors.
Tests (10 new sunset cases on top of the previous 6):
- Full-path happy case asserts safe-state PRECEDES GC PRECEDES
tombstone via a shared CallOrder log.
- Safe-state failure path: NO GC + NO tombstone (tenant state preserved).
- Per-step failure paths for SkippedOnGcFailure / SkippedOnGlobalDeleteFailure.
- _seeded flag stays false on partial sunset, flips to true only on
a clean cycle; the "next call retries" scenario is exercised by
reconfiguring the mock between two GetAllRulesForTenantAsync calls.
Backend 2082/2082.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,83 +19,189 @@ public class AnalyzeRuleServiceSunsetTests
|
||||
private const string SunsetRuleId = "ANALYZE-LEGACY-001";
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_drops_sunset_rule_and_GCs_orphan_RuleStates()
|
||||
public async Task EnsureSeed_full_sunset_path_runs_safeState_then_GC_then_tombstone()
|
||||
{
|
||||
// Arrange: DB has a built-in rule that's no longer in the code catalog
|
||||
// (BuiltInAnalyzeRules.GetAll() returns the embedded analyze-rules.json — we
|
||||
// can't easily inject a fake catalog, so we use a ruleId that the real catalog
|
||||
// doesn't contain). Plus the rule has TWO per-tenant RuleState overrides that
|
||||
// would normally keep it firing.
|
||||
var (ruleRepo, deleteAnalyzeCalls, gcCalls) = BuildRepoWithSunsetRule(SunsetRuleId, gcDeleted: 2, gcFailed: 0);
|
||||
// Happy path: every step succeeds. We assert the side-effects of each step
|
||||
// landed AND that they happened in the correct order — safe-state (write
|
||||
// with Enabled=false) MUST precede GC (delete orphan tenant state).
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 2, gcFailed: 0);
|
||||
|
||||
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
// Act: a regular GetAllRulesForTenantAsync triggers EnsureBuiltInRulesSeededAsync.
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
await service.GetAllRulesForTenantAsync("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
|
||||
// Assert: the sunset rule's per-tenant orphan RuleStates got GC'd AND its
|
||||
// global row got deleted. The order matters (GC first), but for a clean GC
|
||||
// the end-state is the same: both call sites recorded.
|
||||
Assert.Contains(SunsetRuleId, deleteAnalyzeCalls);
|
||||
Assert.Contains(SunsetRuleId, gcCalls);
|
||||
Assert.Contains(SunsetRuleId, rig.SafeStateWrites);
|
||||
Assert.Contains(SunsetRuleId, rig.GcCalls);
|
||||
Assert.Contains(SunsetRuleId, rig.DeleteAnalyzeCalls);
|
||||
// Order: safe-state was recorded BEFORE the first GC call.
|
||||
Assert.True(
|
||||
rig.CallOrder.IndexOf($"store:{SunsetRuleId}") < rig.CallOrder.IndexOf($"gc:{SunsetRuleId}"),
|
||||
"Safe-state write must precede the orphan-GC for the same ruleId.");
|
||||
Assert.True(
|
||||
rig.CallOrder.IndexOf($"gc:{SunsetRuleId}") < rig.CallOrder.IndexOf($"delete:{SunsetRuleId}"),
|
||||
"Orphan-GC must precede the global tombstone delete for the same ruleId.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_safeState_failure_short_circuits_before_GC_and_delete()
|
||||
{
|
||||
// Codex review (Medium): the previous fix still let tenant RuleStates get
|
||||
// deleted before a transient Azure error blew up the global tombstone delete,
|
||||
// which could silently flip a future default-ENABLED sunset rule's
|
||||
// RuleState{Enabled=false} opt-outs back to enabled (because the tenant
|
||||
// override is gone and the global is still enabled).
|
||||
// Fix: safe-state (global Enabled=false) MUST succeed before any tenant state
|
||||
// is touched. If the safe-state write itself fails, we abort the whole sunset
|
||||
// sequence for that rule — no GC, no tombstone.
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 0, gcFailed: 0, safeStateOk: false);
|
||||
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
await service.GetAllRulesForTenantAsync("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
|
||||
Assert.Contains(SunsetRuleId, rig.SafeStateWrites); // safe-state was attempted
|
||||
Assert.DoesNotContain(SunsetRuleId, rig.GcCalls); // GC NOT attempted (tenant state preserved)
|
||||
Assert.DoesNotContain(SunsetRuleId, rig.DeleteAnalyzeCalls); // global delete NOT attempted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_partial_GC_failure_keeps_global_row_so_retry_works()
|
||||
{
|
||||
// Codex review (Medium): if GC reports >0 failed rows, the global delete must NOT
|
||||
// run — otherwise the rule falls out of the sunset-diff and the remaining orphan
|
||||
// RuleState rows are unreachable. With the global row kept, the next startup will
|
||||
// see the rule in the diff again and re-attempt the GC.
|
||||
var (ruleRepo, deleteAnalyzeCalls, gcCalls) = BuildRepoWithSunsetRule(SunsetRuleId, gcDeleted: 1, gcFailed: 1);
|
||||
// GC reports >0 failed rows → global delete must NOT run, the rule stays in
|
||||
// the sunset diff for the next cycle's retry. Safe-state (Enabled=false) is
|
||||
// already written, so default-enabled sunset rules don't reactivate any opt-out
|
||||
// tenant in the meantime.
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 1, gcFailed: 1);
|
||||
|
||||
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
await service.GetAllRulesForTenantAsync("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
|
||||
Assert.Contains(SunsetRuleId, gcCalls); // GC was attempted
|
||||
Assert.DoesNotContain(SunsetRuleId, deleteAnalyzeCalls); // global delete SKIPPED on partial GC
|
||||
Assert.Contains(SunsetRuleId, rig.SafeStateWrites);
|
||||
Assert.Contains(SunsetRuleId, rig.GcCalls);
|
||||
Assert.DoesNotContain(SunsetRuleId, rig.DeleteAnalyzeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_GC_enumeration_failure_keeps_global_row_too()
|
||||
{
|
||||
// The gcFailed == -1 sentinel signals "we couldn't enumerate the RuleStates at
|
||||
// all". Same contract as partial failure: do NOT delete the global row.
|
||||
var (ruleRepo, deleteAnalyzeCalls, gcCalls) = BuildRepoWithSunsetRule(SunsetRuleId, gcDeleted: 0, gcFailed: -1);
|
||||
// gcFailed == -1 sentinel = enumeration itself failed (we don't know how many
|
||||
// orphans are out there). Same contract as partial failure: keep global row.
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 0, gcFailed: -1);
|
||||
|
||||
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
await service.GetAllRulesForTenantAsync("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
|
||||
Assert.Contains(SunsetRuleId, gcCalls);
|
||||
Assert.DoesNotContain(SunsetRuleId, deleteAnalyzeCalls);
|
||||
Assert.Contains(SunsetRuleId, rig.GcCalls);
|
||||
Assert.DoesNotContain(SunsetRuleId, rig.DeleteAnalyzeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_partial_sunset_failure_leaves_seed_flag_clearable_for_same_instance_retry()
|
||||
{
|
||||
// Codex review (Medium): the previous fix unconditionally set _seeded=true at
|
||||
// the end of EnsureSeed, so a sunset that didn't complete had to wait for a
|
||||
// process restart to retry. On long-running Function-App instances this can
|
||||
// mean hours of delay. New contract: if ANY sunset rule didn't reach
|
||||
// Completed, _seeded stays false → next GetAllRulesForTenantAsync re-enters
|
||||
// EnsureSeed and retries the sunset path.
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 0, gcFailed: 1);
|
||||
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var tenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
await service.GetAllRulesForTenantAsync(tenantId);
|
||||
var gcCallsAfterFirst = rig.GcCalls.Count;
|
||||
// Now switch the mock so the retry has a clean GC — should complete on retry.
|
||||
rig.SetGcReturn(gcDeleted: 1, gcFailed: 0);
|
||||
|
||||
await service.GetAllRulesForTenantAsync(tenantId);
|
||||
Assert.True(rig.GcCalls.Count > gcCallsAfterFirst,
|
||||
"Sunset should be retried on the next call when the previous attempt didn't complete.");
|
||||
Assert.Contains(SunsetRuleId, rig.DeleteAnalyzeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_all_sunsets_completed_sets_seeded_flag()
|
||||
{
|
||||
// Complement of the previous test: on a clean sunset cycle, _seeded gets set
|
||||
// so we don't re-scan on every call. (Verified indirectly: a second call
|
||||
// doesn't issue another GC against the now-deleted rule.)
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 1, gcFailed: 0);
|
||||
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var tenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
await service.GetAllRulesForTenantAsync(tenantId);
|
||||
var gcCallsAfterFirst = rig.GcCalls.Count;
|
||||
await service.GetAllRulesForTenantAsync(tenantId);
|
||||
|
||||
Assert.Equal(gcCallsAfterFirst, rig.GcCalls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSeed_does_not_GC_RuleStates_for_rules_still_in_catalog()
|
||||
{
|
||||
// Arrange: pick a rule that IS still in the catalog. EnsureSeed must NOT touch
|
||||
// its RuleState rows even if tenants have overrides — those are legitimate opt-out
|
||||
// / opt-in settings.
|
||||
// Live rules with tenant overrides MUST NOT be touched — those are legitimate
|
||||
// enabled/disabled preferences, not orphans.
|
||||
var stillLiveRuleId = BuiltInAnalyzeRules.GetAll().First().RuleId;
|
||||
var (ruleRepo, deleteAnalyzeCalls, gcCalls) = BuildRepoWithSunsetRule(stillLiveRuleId, gcDeleted: 1, gcFailed: 0);
|
||||
var rig = new SunsetRig(stillLiveRuleId, gcDeleted: 1, gcFailed: 0);
|
||||
|
||||
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
await service.GetAllRulesForTenantAsync("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
|
||||
// Live rule's RuleState must NOT be GC'd.
|
||||
Assert.DoesNotContain(stillLiveRuleId, gcCalls);
|
||||
Assert.DoesNotContain(stillLiveRuleId, rig.GcCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSunsetRuleAsync_returns_SkippedOnSafeStateFailure_when_safe_state_fails()
|
||||
{
|
||||
// Direct unit test of the helper — covers the safe-state branch independent
|
||||
// of EnsureSeed plumbing.
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 0, gcFailed: 0, safeStateOk: false);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var sunsetRule = new AnalyzeRule { RuleId = SunsetRuleId, IsBuiltIn = true, Enabled = true };
|
||||
var (outcome, gcd) = await service.ProcessSunsetRuleAsync(sunsetRule);
|
||||
|
||||
Assert.Equal(SunsetOutcome.SkippedOnSafeStateFailure, outcome);
|
||||
Assert.Equal(0, gcd);
|
||||
// Safe-state mutated the in-memory rule (defense-in-depth for callers that
|
||||
// re-use the object — they see the neutralised values, not the original).
|
||||
Assert.False(sunsetRule.Enabled);
|
||||
Assert.False(sunsetRule.MarkSessionAsFailedDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSunsetRuleAsync_returns_SkippedOnGcFailure_when_GC_reports_failures()
|
||||
{
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 2, gcFailed: 1);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var sunsetRule = new AnalyzeRule { RuleId = SunsetRuleId, IsBuiltIn = true, Enabled = true };
|
||||
var (outcome, gcd) = await service.ProcessSunsetRuleAsync(sunsetRule);
|
||||
|
||||
Assert.Equal(SunsetOutcome.SkippedOnGcFailure, outcome);
|
||||
Assert.Equal(2, gcd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSunsetRuleAsync_returns_SkippedOnGlobalDeleteFailure_when_tombstone_fails()
|
||||
{
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 3, gcFailed: 0, globalDeleteOk: false);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var sunsetRule = new AnalyzeRule { RuleId = SunsetRuleId, IsBuiltIn = true, Enabled = true };
|
||||
var (outcome, gcd) = await service.ProcessSunsetRuleAsync(sunsetRule);
|
||||
|
||||
Assert.Equal(SunsetOutcome.SkippedOnGlobalDeleteFailure, outcome);
|
||||
Assert.Equal(3, gcd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedBuiltIn_returns_orphanGc_count_in_tuple()
|
||||
{
|
||||
// The new ReseedBuiltInRulesAsync return shape is (deleted, written, orphanStatesGcd)
|
||||
// — pin that so external callers (admin UI, future scripts) can surface the
|
||||
// cleanup count to the operator.
|
||||
var (ruleRepo, _, _) = BuildRepoWithSunsetRule(SunsetRuleId, gcDeleted: 3, gcFailed: 0);
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 3, gcFailed: 0);
|
||||
|
||||
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var (deleted, written, orphanStatesGcd) = await service.ReseedBuiltInRulesAsync();
|
||||
|
||||
Assert.True(deleted >= 1, $"Expected at least 1 deletion (incl. sunset), got {deleted}");
|
||||
@@ -106,70 +212,96 @@ public class AnalyzeRuleServiceSunsetTests
|
||||
[Fact]
|
||||
public async Task ReseedBuiltIn_skips_global_delete_on_partial_GC_failure()
|
||||
{
|
||||
// Same invariant as the EnsureSeed test — the explicit Reseed path must also
|
||||
// refuse to drop the global row when GC didn't go all the way through.
|
||||
var (ruleRepo, deleteAnalyzeCalls, gcCalls) = BuildRepoWithSunsetRule(SunsetRuleId, gcDeleted: 2, gcFailed: 1);
|
||||
var rig = new SunsetRig(SunsetRuleId, gcDeleted: 2, gcFailed: 1);
|
||||
|
||||
var service = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var service = new AnalyzeRuleService(rig.Repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var (deleted, written, orphanStatesGcd) = await service.ReseedBuiltInRulesAsync();
|
||||
|
||||
Assert.Contains(SunsetRuleId, gcCalls);
|
||||
Assert.DoesNotContain(SunsetRuleId, deleteAnalyzeCalls);
|
||||
// orphanStatesGcd still surfaces the partial successes (2/3 rows deleted before
|
||||
// the failure) so operators can see progress was made.
|
||||
Assert.Contains(SunsetRuleId, rig.GcCalls);
|
||||
Assert.DoesNotContain(SunsetRuleId, rig.DeleteAnalyzeCalls);
|
||||
Assert.Equal(2, orphanStatesGcd);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
/// <summary>
|
||||
/// Builds a mock <see cref="IRuleRepository"/> that:
|
||||
/// (a) returns the full built-in catalog PLUS one extra rule with the given
|
||||
/// <paramref name="extraRuleId"/> on the "global" partition — so the sunset
|
||||
/// detection has something to find when extraRuleId is not in the catalog;
|
||||
/// (b) reports the configured GC result via
|
||||
/// <see cref="IRuleRepository.DeleteRuleStatesForRuleIdAcrossTenantsAsync"/>.
|
||||
/// Returns the mock plus two recorder lists capturing which ruleIds got
|
||||
/// DeleteAnalyzeRuleAsync and which got DeleteRuleStatesForRuleIdAcrossTenantsAsync
|
||||
/// invoked.
|
||||
/// Mock harness for sunset-path tests. Records every storage call against the
|
||||
/// sunset ruleId — safe-state writes (StoreAnalyzeRuleAsync), orphan GC, and
|
||||
/// tombstone deletes — and lets the test configure success/failure of each step.
|
||||
/// The <see cref="CallOrder"/> sequence captures relative ordering so tests can
|
||||
/// assert the safe-state → GC → tombstone invariant directly.
|
||||
/// </summary>
|
||||
private static (Mock<IRuleRepository> repo, List<string> deleteAnalyzeCalls, List<string> gcCalls)
|
||||
BuildRepoWithSunsetRule(string extraRuleId, int gcDeleted, int gcFailed)
|
||||
private sealed class SunsetRig
|
||||
{
|
||||
var deleteAnalyzeCalls = new List<string>();
|
||||
var gcCalls = new List<string>();
|
||||
public Mock<IRuleRepository> Repo { get; }
|
||||
public List<string> SafeStateWrites { get; } = new();
|
||||
public List<string> GcCalls { get; } = new();
|
||||
public List<string> DeleteAnalyzeCalls { get; } = new();
|
||||
public List<string> CallOrder { get; } = new();
|
||||
|
||||
// The "existing" set in the DB: real built-ins (so version-bump logic doesn't
|
||||
// re-write everything every test) PLUS one extra synthetic entry. The extra
|
||||
// entry mimics a previous build-in that has since been removed from the code.
|
||||
var existingInDb = BuiltInAnalyzeRules.GetAll().ToList();
|
||||
if (!existingInDb.Any(r => r.RuleId == extraRuleId))
|
||||
private (int gcDeleted, int gcFailed) _gcReturn;
|
||||
|
||||
public SunsetRig(string sunsetRuleId, int gcDeleted, int gcFailed,
|
||||
bool safeStateOk = true, bool globalDeleteOk = true)
|
||||
{
|
||||
existingInDb.Add(new AnalyzeRule
|
||||
_gcReturn = (gcDeleted, gcFailed);
|
||||
|
||||
// Existing DB = real catalog + the synthetic sunset row.
|
||||
var existingInDb = BuiltInAnalyzeRules.GetAll().ToList();
|
||||
if (!existingInDb.Any(r => r.RuleId == sunsetRuleId))
|
||||
{
|
||||
RuleId = extraRuleId,
|
||||
Title = "Legacy",
|
||||
Description = "Legacy",
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
Version = "1.0.0",
|
||||
IsBuiltIn = true,
|
||||
Enabled = false,
|
||||
});
|
||||
existingInDb.Add(new AnalyzeRule
|
||||
{
|
||||
RuleId = sunsetRuleId,
|
||||
Title = "Legacy",
|
||||
Description = "Legacy",
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
Version = "1.0.0",
|
||||
IsBuiltIn = true,
|
||||
Enabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
Repo = new Mock<IRuleRepository>();
|
||||
Repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(existingInDb);
|
||||
Repo.Setup(r => r.GetAnalyzeRulesAsync(It.Is<string>(s => s != "global"))).ReturnsAsync(new List<AnalyzeRule>());
|
||||
Repo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
|
||||
// StoreAnalyzeRuleAsync: only flip safeStateOk for writes against the
|
||||
// sunset ruleId — survivors / new rules in the catalog must still write OK.
|
||||
Repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()))
|
||||
.Callback<AnalyzeRule, string>((rule, _) =>
|
||||
{
|
||||
if (rule.RuleId == sunsetRuleId)
|
||||
{
|
||||
SafeStateWrites.Add(rule.RuleId);
|
||||
CallOrder.Add($"store:{rule.RuleId}");
|
||||
}
|
||||
})
|
||||
.ReturnsAsync((AnalyzeRule rule, string _) => rule.RuleId == sunsetRuleId ? safeStateOk : true);
|
||||
|
||||
Repo.Setup(r => r.DeleteAnalyzeRuleAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback<string, string>((_, ruleId) =>
|
||||
{
|
||||
DeleteAnalyzeCalls.Add(ruleId);
|
||||
CallOrder.Add($"delete:{ruleId}");
|
||||
})
|
||||
.ReturnsAsync((string _, string ruleId) => ruleId == sunsetRuleId ? globalDeleteOk : true);
|
||||
|
||||
Repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>()))
|
||||
.Callback<string>(ruleId =>
|
||||
{
|
||||
GcCalls.Add(ruleId);
|
||||
CallOrder.Add($"gc:{ruleId}");
|
||||
})
|
||||
.ReturnsAsync(() => _gcReturn);
|
||||
}
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(existingInDb);
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(It.Is<string>(s => s != "global"))).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
ruleRepo.Setup(r => r.DeleteAnalyzeRuleAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback<string, string>((_, ruleId) => deleteAnalyzeCalls.Add(ruleId))
|
||||
.ReturnsAsync(true);
|
||||
ruleRepo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>()))
|
||||
.Callback<string>(ruleId => gcCalls.Add(ruleId))
|
||||
.ReturnsAsync((gcDeleted, gcFailed));
|
||||
|
||||
return (ruleRepo, deleteAnalyzeCalls, gcCalls);
|
||||
/// <summary>
|
||||
/// Reconfigure the GC return value mid-test. Used by the
|
||||
/// "next call retries after a transient failure" scenario.
|
||||
/// </summary>
|
||||
public void SetGcReturn(int gcDeleted, int gcFailed) => _gcReturn = (gcDeleted, gcFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,33 +181,21 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Sunset path: GC-first ordering. If GC fails partially, leave the global
|
||||
// row in place so the next reseed cycle sees the rule in the sunset diff
|
||||
// again and retries.
|
||||
// Sunset path: shared helper on AnalyzeRuleService — same safe-state ->
|
||||
// GC -> tombstone ordering as the in-process EnsureSeed and ReseedBuiltIn
|
||||
// paths, so every reseed flavor leaves the same DB invariants.
|
||||
var orphanStatesGcd = 0;
|
||||
var sunsetSkipped = 0;
|
||||
foreach (var sunsetRule in sunset)
|
||||
{
|
||||
var (gcDeleted, gcFailed) = await _ruleRepo.DeleteRuleStatesForRuleIdAcrossTenantsAsync(sunsetRule.RuleId);
|
||||
orphanStatesGcd += gcDeleted;
|
||||
if (gcFailed != 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GitHub reseed sunset GC partial failure for {RuleId} (deleted={Deleted}, failed={Failed}); keeping global row to retry on next reseed",
|
||||
sunsetRule.RuleId, gcDeleted, gcFailed);
|
||||
sunsetSkipped++;
|
||||
continue;
|
||||
}
|
||||
var globalDeleted = await _ruleRepo.DeleteAnalyzeRuleAsync("global", sunsetRule.RuleId);
|
||||
if (globalDeleted)
|
||||
var (outcome, gcd) = await _analyzeRuleService.ProcessSunsetRuleAsync(sunsetRule);
|
||||
orphanStatesGcd += gcd;
|
||||
if (outcome == AutopilotMonitor.Functions.Services.SunsetOutcome.Completed)
|
||||
{
|
||||
deleted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GitHub reseed sunset GC clean but global delete failed for {RuleId}; will retry on next reseed",
|
||||
sunsetRule.RuleId);
|
||||
sunsetSkipped++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// in the RuleStates table, so rule definitions can be updated centrally without
|
||||
/// losing per-tenant enabled/disabled preferences.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="AnalyzeRuleService.ProcessSunsetRuleAsync"/>. Callers use
|
||||
/// this to decide whether the seed cycle is fully consistent (only
|
||||
/// <see cref="Completed"/>) or whether a retry on the next cycle is needed (any
|
||||
/// other value). Order of the failure values mirrors the safe-state → GC → delete
|
||||
/// sequence — earlier failures indicate less state was changed.
|
||||
/// </summary>
|
||||
public enum SunsetOutcome
|
||||
{
|
||||
/// <summary>Safe-state, orphan-GC, and global-delete all succeeded.</summary>
|
||||
Completed,
|
||||
/// <summary>Safe-state (set global Enabled=false) failed. No GC or delete attempted. Rule keeps whatever Enabled value it had before — at worst this leaves it active, never silently flipping a tenant opt-out.</summary>
|
||||
SkippedOnSafeStateFailure,
|
||||
/// <summary>Safe-state succeeded, but the cross-tenant orphan-GC failed (per-row or enumeration). Global rule stays as Enabled=false so for the typical default-disabled sunset case the rule is already effectively dead; default-enabled sunsets are also safe because tenant opt-outs still win over the new Enabled=false global default until the GC retry deletes them next cycle.</summary>
|
||||
SkippedOnGcFailure,
|
||||
/// <summary>Safe-state + GC succeeded, but the global tombstone delete failed. Rule is effectively dead (no tenant overrides, global Enabled=false); next seed cycle will retry the tombstone.</summary>
|
||||
SkippedOnGlobalDeleteFailure,
|
||||
}
|
||||
|
||||
public class AnalyzeRuleService
|
||||
{
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
@@ -24,6 +43,71 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives the sunset sequence for a single built-in / community rule that is no
|
||||
/// longer in the code catalog. Three steps, executed in order, with strict
|
||||
/// "later step requires earlier success" semantics so a partial failure can
|
||||
/// never silently flip a tenant's enabled/disabled preference for the rule:
|
||||
/// <list type="number">
|
||||
/// <item><b>Safe-state</b>: re-write the global row with
|
||||
/// <c>Enabled = false</c> and <c>MarkSessionAsFailedDefault = false</c>.
|
||||
/// Defends future default-ENABLED sunset rules: even if the next two
|
||||
/// steps fail, the global default is now off, so tenants with
|
||||
/// <c>RuleState{Enabled=false}</c> (explicit opt-outs) keep their opt-out
|
||||
/// behaviour (override matches global default, both off).</item>
|
||||
/// <item><b>Orphan GC</b>: delete every per-tenant <c>RuleState</c> row whose
|
||||
/// RowKey matches the sunset rule. Skipped if safe-state failed — we never
|
||||
/// destroy tenant state before the global default has been neutralised.</item>
|
||||
/// <item><b>Tombstone</b>: delete the global rule row. Skipped if GC reported
|
||||
/// any failures, so the rule remains in the sunset diff for the next seed
|
||||
/// cycle's retry.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="rule">The current global rule object (will be mutated by the
|
||||
/// safe-state step; callers should not reuse it).</param>
|
||||
/// <returns>An outcome + count of orphan RuleState rows that this attempt
|
||||
/// actually deleted (always >= 0). The count surfaces partial progress even
|
||||
/// on a <see cref="SunsetOutcome.SkippedOnGcFailure"/> result so operators can
|
||||
/// see the rule getting cleaned up across cycles.</returns>
|
||||
public async Task<(SunsetOutcome outcome, int orphanStatesGcd)> ProcessSunsetRuleAsync(AnalyzeRule rule)
|
||||
{
|
||||
// Step 1: safe-state. Neutralise the global default first so any orphan
|
||||
// state we fail to clean below is harmless.
|
||||
rule.Enabled = false;
|
||||
rule.MarkSessionAsFailedDefault = false;
|
||||
rule.UpdatedAt = DateTime.UtcNow;
|
||||
var safeStateOk = await _ruleRepo.StoreAnalyzeRuleAsync(rule, "global");
|
||||
if (!safeStateOk)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sunset safe-state write failed for {RuleId}; skipping GC + tombstone, will retry next seed cycle",
|
||||
rule.RuleId);
|
||||
return (SunsetOutcome.SkippedOnSafeStateFailure, 0);
|
||||
}
|
||||
|
||||
// Step 2: orphan-GC.
|
||||
var (gcDeleted, gcFailed) = await _ruleRepo.DeleteRuleStatesForRuleIdAcrossTenantsAsync(rule.RuleId);
|
||||
if (gcFailed != 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sunset GC partial failure for {RuleId} (deleted={Deleted}, failed={Failed}); rule is now safe-stated (Enabled=false) globally, will retry GC + tombstone next seed cycle",
|
||||
rule.RuleId, gcDeleted, gcFailed);
|
||||
return (SunsetOutcome.SkippedOnGcFailure, gcDeleted);
|
||||
}
|
||||
|
||||
// Step 3: tombstone.
|
||||
var globalDeleted = await _ruleRepo.DeleteAnalyzeRuleAsync("global", rule.RuleId);
|
||||
if (!globalDeleted)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sunset GC clean but global delete failed for {RuleId}; rule is effectively dead (Enabled=false + no tenant overrides) but the row will be retried next seed cycle",
|
||||
rule.RuleId);
|
||||
return (SunsetOutcome.SkippedOnGlobalDeleteFailure, gcDeleted);
|
||||
}
|
||||
|
||||
return (SunsetOutcome.Completed, gcDeleted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active analyze rules for a tenant (enabled only)
|
||||
/// </summary>
|
||||
@@ -250,39 +334,23 @@ namespace AutopilotMonitor.Functions.Services
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Sunset path: GC-first ordering.
|
||||
// Sunset path: full safe-state -> GC -> tombstone sequence via the shared
|
||||
// helper. Each rule that reaches Completed contributes one to `deleted`.
|
||||
// Partial-failure paths leave the global row in place for the next cycle.
|
||||
var orphanStatesGcd = 0;
|
||||
var sunsetSkippedOnGcFailure = 0;
|
||||
var sunsetSkippedOnGlobalDeleteFailure = 0;
|
||||
var outcomeCounts = new Dictionary<SunsetOutcome, int>();
|
||||
foreach (var sunsetRule in sunset)
|
||||
{
|
||||
var (gcDeleted, gcFailed) = await _ruleRepo.DeleteRuleStatesForRuleIdAcrossTenantsAsync(sunsetRule.RuleId);
|
||||
orphanStatesGcd += gcDeleted;
|
||||
if (gcFailed != 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Reseed sunset GC partial failure for {RuleId} (deleted={Deleted}, failed={Failed}); keeping global row to retry next cycle",
|
||||
sunsetRule.RuleId, gcDeleted, gcFailed);
|
||||
sunsetSkippedOnGcFailure++;
|
||||
continue;
|
||||
}
|
||||
var globalDeleted = await _ruleRepo.DeleteAnalyzeRuleAsync("global", sunsetRule.RuleId);
|
||||
if (globalDeleted)
|
||||
{
|
||||
var (outcome, gcd) = await ProcessSunsetRuleAsync(sunsetRule);
|
||||
orphanStatesGcd += gcd;
|
||||
outcomeCounts[outcome] = outcomeCounts.GetValueOrDefault(outcome) + 1;
|
||||
if (outcome == SunsetOutcome.Completed)
|
||||
deleted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Reseed sunset GC clean but global delete failed for {RuleId}; will retry next cycle",
|
||||
sunsetRule.RuleId);
|
||||
sunsetSkippedOnGlobalDeleteFailure++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reseed analyze rules: {Deleted} old rows deleted, {Survivors} survivors, {Sunset} sunset (GC skip={GcSkip}, delete skip={DelSkip}, orphan states removed={OrphanGcd})",
|
||||
deleted, survivors.Count, sunset.Count, sunsetSkippedOnGcFailure, sunsetSkippedOnGlobalDeleteFailure, orphanStatesGcd);
|
||||
"Reseed analyze rules: {Deleted} rows deleted, {Survivors} survivors, {Sunset} sunset (outcomes {Outcomes}, orphan states removed={OrphanGcd})",
|
||||
deleted, survivors.Count, sunset.Count, FormatOutcomes(outcomeCounts), orphanStatesGcd);
|
||||
|
||||
foreach (var rule in builtInRules)
|
||||
{
|
||||
@@ -351,62 +419,59 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
// Sunset detection: rules that existed as built-in but are no longer in
|
||||
// the code catalog. Delete the global row AND fan out the per-tenant
|
||||
// RuleState GC so opt-in overrides don't linger as dead state.
|
||||
//
|
||||
// Order matters and is deliberate: orphan-GC FIRST, then global delete.
|
||||
// If GC fails partially we leave the global row in place so the next
|
||||
// startup sees the rule in the sunset diff again and retries the cleanup.
|
||||
// The previous order (global-delete-first) lost retry-ability because
|
||||
// once the global row is gone the diff goes empty and any remaining
|
||||
// orphan RuleStates would linger indefinitely.
|
||||
// the code catalog. ProcessSunsetRuleAsync handles the safe-state -> GC
|
||||
// -> tombstone sequence per rule with strict ordering guarantees (see
|
||||
// its XML doc). Any non-Completed outcome means there's still work to
|
||||
// do — we leave _seeded = false so the next GetAllRulesForTenantAsync
|
||||
// call retries the seed without waiting for a process restart.
|
||||
var sunsetRules = existingRules
|
||||
.Where(r => r.IsBuiltIn && !newCatalogIds.Contains(r.RuleId))
|
||||
.ToList();
|
||||
|
||||
var allSunsetsClean = true;
|
||||
if (sunsetRules.Count > 0)
|
||||
{
|
||||
var orphanStatesGcd = 0;
|
||||
var skippedOnGcFailure = 0;
|
||||
var skippedOnGlobalDeleteFailure = 0;
|
||||
var outcomeCounts = new Dictionary<SunsetOutcome, int>();
|
||||
foreach (var sunset in sunsetRules)
|
||||
{
|
||||
var (gcDeleted, gcFailed) = await _ruleRepo.DeleteRuleStatesForRuleIdAcrossTenantsAsync(sunset.RuleId);
|
||||
if (gcFailed != 0)
|
||||
{
|
||||
// Either per-row error (failed > 0) or enumeration failure (-1).
|
||||
// Either way we cannot safely drop the global row — retry next
|
||||
// startup. Orphan-State rows that DID get deleted in this pass
|
||||
// are gone for good, but that's fine — the per-row delete is
|
||||
// already 404-idempotent.
|
||||
_logger.LogWarning(
|
||||
"Sunset GC partial failure for {RuleId} (deleted={Deleted}, failed={Failed}); keeping global row to retry on next seed cycle",
|
||||
sunset.RuleId, gcDeleted, gcFailed);
|
||||
skippedOnGcFailure++;
|
||||
orphanStatesGcd += gcDeleted;
|
||||
continue;
|
||||
}
|
||||
|
||||
var globalDeleted = await _ruleRepo.DeleteAnalyzeRuleAsync("global", sunset.RuleId);
|
||||
if (!globalDeleted)
|
||||
{
|
||||
// GC was clean but the global delete itself failed (transient
|
||||
// Azure error). The orphan states are already gone; the global
|
||||
// row will be retried on next startup (GC will then no-op).
|
||||
_logger.LogWarning(
|
||||
"Sunset GC clean but global delete failed for {RuleId}; will retry next seed cycle",
|
||||
sunset.RuleId);
|
||||
skippedOnGlobalDeleteFailure++;
|
||||
}
|
||||
orphanStatesGcd += gcDeleted;
|
||||
var (outcome, gcd) = await ProcessSunsetRuleAsync(sunset);
|
||||
orphanStatesGcd += gcd;
|
||||
outcomeCounts[outcome] = outcomeCounts.GetValueOrDefault(outcome) + 1;
|
||||
if (outcome != SunsetOutcome.Completed)
|
||||
allSunsetsClean = false;
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Sunset {RuleCount} built-in analyze rule(s); GC'd {OrphanCount} orphan per-tenant RuleState row(s); skipped {GcSkip} on GC failure, {DelSkip} on global-delete failure",
|
||||
sunsetRules.Count, orphanStatesGcd, skippedOnGcFailure, skippedOnGlobalDeleteFailure);
|
||||
"Sunset {RuleCount} built-in analyze rule(s); orphan RuleState rows GC'd {OrphanCount}; outcomes {Outcomes}",
|
||||
sunsetRules.Count, orphanStatesGcd, FormatOutcomes(outcomeCounts));
|
||||
}
|
||||
|
||||
// Only flip the seed-once flag when EVERY sunset rule reached the
|
||||
// Completed terminal. If any was partial, the next call will retry —
|
||||
// important on long-running Function-App instances that don't recycle
|
||||
// often. (Survivors / new-rule writes above are idempotent: they all
|
||||
// go through Upsert and don't churn DB state on retry.)
|
||||
if (!allSunsetsClean)
|
||||
{
|
||||
_logger.LogInformation("One or more sunset rules did not complete; leaving _seeded=false so the next GetAllRulesForTenantAsync retries.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_seeded = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compact log-friendly rendering of the sunset-outcome histogram. Inline so
|
||||
/// the LogInformation site stays readable. Order matches the SunsetOutcome enum
|
||||
/// (Completed first, then failures in execution-step order).
|
||||
/// </summary>
|
||||
private static string FormatOutcomes(Dictionary<SunsetOutcome, int> counts)
|
||||
{
|
||||
return string.Join(", ",
|
||||
Enum.GetValues<SunsetOutcome>()
|
||||
.Where(o => counts.GetValueOrDefault(o) > 0)
|
||||
.Select(o => $"{o}={counts[o]}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user