mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(backend): revert rehydration - whole-valued double columns must not become Int32
Prod finding during the first live E2E (gktatooine): revert_tenant_config failed fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish 95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns (decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble throws on the type mismatch. The rehydrator now forces GetDouble for columns the model declares as decimal/double/float, mirroring the existing DateTime handling. The roundtrip canary test is hardened into a reflection filler that sets EVERY writable property non-default (with a deliberately whole-numbered decimal), so no column type can escape the fidelity check again; plus a focused pin on the exact prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused the write, the snapshot itself is intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -488,24 +488,60 @@ public class TenantConfigPatchServiceTests
|
||||
// ── Rehydration roundtrip (serialization-drift canary) ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_Roundtrip_ProducesIdenticalModel()
|
||||
public void RehydrateEntity_Roundtrip_EveryPropertyNonNull_ProducesIdenticalModel()
|
||||
{
|
||||
var original = Stored();
|
||||
original.ContactEmail = "ops@contoso.com";
|
||||
original.TrialExpiresUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
original.LastAuthClientIdSince = new DateTime(2026, 7, 31, 18, 3, 0, DateTimeKind.Utc);
|
||||
original.CustomRateLimitRequestsPerMinute = 42;
|
||||
// Reflection filler: EVERY writable property gets a non-default value so no column
|
||||
// escapes the fidelity check. Whole-number decimals (95m) are the deliberate trap —
|
||||
// JSON serializes 95.0 as "95", and prod finding 2026-08-03 was exactly a whole-valued
|
||||
// SLA rate column rehydrating as Int32 and blowing up TableEntity.GetDouble.
|
||||
var original = TenantConfiguration.CreateDefault(TenantId);
|
||||
foreach (var prop in typeof(TenantConfiguration).GetProperties()
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0))
|
||||
{
|
||||
var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
object? value =
|
||||
t == typeof(string) ? (prop.Name is "PartitionKey" or "RowKey" or "TenantId"
|
||||
? prop.GetValue(original) : $"x-{prop.Name}")
|
||||
: t == typeof(bool) ? true
|
||||
: t == typeof(int) ? 7
|
||||
: t == typeof(decimal) ? 95m // whole number on purpose — the Int32/Double trap
|
||||
: t == typeof(double) ? 95d
|
||||
: t == typeof(DateTime) ? new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc)
|
||||
: prop.GetValue(original);
|
||||
prop.SetValue(original, value);
|
||||
}
|
||||
|
||||
// Canonical baseline = one Store/Map roundtrip (isolates REHYDRATION fidelity from any
|
||||
// pre-existing converter quirks, which TenantConfigTableSerializationTests own).
|
||||
var baseline = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original));
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(baseline),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer
|
||||
.GetChangedPropertyNames(baseline, rehydrated);
|
||||
Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_WholeNumberDecimalColumn_SurvivesAsDouble()
|
||||
{
|
||||
// Focused pin on the prod failure: SlaTargetSuccessRate 95 (whole) must come back as
|
||||
// a readable double column, not an Int32 that makes GetDouble throw.
|
||||
var original = Stored();
|
||||
original.SlaTargetSuccessRate = 95m;
|
||||
|
||||
// Snapshot exactly as the backup hook does, then rehydrate and re-map.
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer
|
||||
.GetChangedPropertyNames(original, rehydrated);
|
||||
Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff));
|
||||
Assert.Equal(95m, rehydrated.SlaTargetSuccessRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -395,7 +395,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Schema-aware rehydration of a raw EntityJson snapshot back into a TableEntity.
|
||||
/// Strings are converted to DateTime ONLY for columns the model declares as DateTime —
|
||||
/// an arbitrary string field that merely looks like a date stays a string.
|
||||
/// an arbitrary string field that merely looks like a date stays a string. Numbers are
|
||||
/// forced to double for columns the model declares as decimal/double/float: JSON cannot
|
||||
/// distinguish 95.0 from 95, so a whole-valued double column would otherwise rehydrate
|
||||
/// as Int32 and TableEntity.GetDouble throws on the type mismatch (prod finding
|
||||
/// 2026-08-03, SLA rate columns).
|
||||
/// </summary>
|
||||
internal static TableEntity RehydrateTenantConfigEntity(string entityJson, string tenantId)
|
||||
{
|
||||
@@ -403,6 +407,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
.Where(p => p.PropertyType == typeof(DateTime) || p.PropertyType == typeof(DateTime?))
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var doubleColumns = ModelProperties.Values
|
||||
.Where(p =>
|
||||
{
|
||||
var t = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType;
|
||||
return t == typeof(decimal) || t == typeof(double) || t == typeof(float);
|
||||
})
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(entityJson);
|
||||
var entity = new TableEntity(tenantId, "config");
|
||||
@@ -415,7 +427,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
System.Text.Json.JsonValueKind.True => true,
|
||||
System.Text.Json.JsonValueKind.False => false,
|
||||
System.Text.Json.JsonValueKind.Number =>
|
||||
prop.Value.TryGetInt32(out var i) ? i
|
||||
doubleColumns.Contains(prop.Name) ? prop.Value.GetDouble()
|
||||
: prop.Value.TryGetInt32(out var i) ? i
|
||||
: prop.Value.TryGetInt64(out var l) ? (object)l
|
||||
: prop.Value.GetDouble(),
|
||||
System.Text.Json.JsonValueKind.String =>
|
||||
|
||||
Reference in New Issue
Block a user