feat(siteruntime): normalize old-form List static overrides to native JSON on load
This commit is contained in:
@@ -948,10 +948,31 @@ public class InstanceActor : ReceiveActor
|
||||
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
|
||||
&& IsListAttribute(resolved))
|
||||
{
|
||||
// NJ-4: decode the stored List override (both old array-of-strings
|
||||
// and native-typed forms decode) and re-persist the native form if
|
||||
// the stored value is still in the OLD form. Re-encoding the decoded
|
||||
// list and comparing to the stored string detects old-form values
|
||||
// (native → native is byte-identical, so a native value is a no-op).
|
||||
// The re-persist is fire-and-forget and never throws into the actor.
|
||||
var decoded = DecodeAttributeValue(resolved, kvp.Value);
|
||||
_attributes[kvp.Key] = decoded;
|
||||
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
|
||||
{
|
||||
_attributeQualities[kvp.Key] = "Bad";
|
||||
}
|
||||
else if (decoded is not null)
|
||||
{
|
||||
var native = AttributeValueCodec.Encode(decoded);
|
||||
if (native != kvp.Value) // stored value was old-form → normalize on disk
|
||||
{
|
||||
var key = kvp.Key;
|
||||
_storage.SetStaticOverrideAsync(_instanceUniqueName, key, native!)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.GetBaseException(),
|
||||
"Failed to normalize static override {Instance}.{Attr} to native JSON",
|
||||
_instanceUniqueName, key),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1016,4 +1016,159 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[]", overrides["Labels"]);
|
||||
}
|
||||
|
||||
// ── NJ-4: old-form List static override normalization on load ────────────
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an
|
||||
/// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>)
|
||||
/// when the actor loads it at startup. The in-memory read still returns the
|
||||
/// typed list {10,20}; the on-disk value is normalized to native JSON.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-OldForm", "Counts", "[\"10\",\"20\"]");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-OldForm",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = "[1,2]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-OldForm", config);
|
||||
|
||||
// Wait for the async override load (PipeTo) + fire-and-forget normalization.
|
||||
await Task.Delay(1000);
|
||||
|
||||
// In-memory read returns the typed list, decoded from the old form.
|
||||
actor.Tell(new GetAttributeRequest("corr-of", "Pump-OldForm", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 10, 20 }, list);
|
||||
|
||||
// The on-disk override has been normalized to the native form.
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-OldForm");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[10,20]", overrides["Counts"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
|
||||
/// load-time normalization must be a no-op — the on-disk value is unchanged
|
||||
/// (idempotent: native → native is byte-identical, so no re-persist occurs).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-Native", "Counts", "[10,20]");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-Native",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = "[1,2]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-Native", config);
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-nat", "Pump-Native", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 10, 20 }, list);
|
||||
|
||||
// The native value is left untouched on disk.
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-Native");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[10,20]", overrides["Counts"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a scalar static override is unaffected by the List normalization path —
|
||||
/// its on-disk value is left exactly as stored (no native re-encode).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-ScalarOf", "Temperature", "200.0");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ScalarOf",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "100.0", DataType = "Double" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-ScalarOf", config);
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-sof", "Pump-ScalarOf", "Temperature", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("200.0", response.Value);
|
||||
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-ScalarOf");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("200.0", overrides["Temperature"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
|
||||
/// actor and must NOT be re-persisted — it loads with Bad quality (as today),
|
||||
/// the actor stays alive, and the poison on-disk value is left unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-BadOf", "Counts", "[\"a\"");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-BadOf",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = "[1,2]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-BadOf", config);
|
||||
Watch(actor);
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bof", "Pump-BadOf", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
Assert.Null(response.Value);
|
||||
|
||||
// The actor must still be alive — no crash from the normalization path.
|
||||
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// The malformed value must NOT have been re-persisted (left exactly as stored).
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-BadOf");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[\"a\"", overrides["Counts"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user