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)
|
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
|
||||||
&& IsListAttribute(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);
|
var decoded = DecodeAttributeValue(resolved, kvp.Value);
|
||||||
_attributes[kvp.Key] = decoded;
|
_attributes[kvp.Key] = decoded;
|
||||||
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
|
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
|
||||||
|
{
|
||||||
_attributeQualities[kvp.Key] = "Bad";
|
_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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1016,4 +1016,159 @@ public class InstanceActorTests : TestKit, IDisposable
|
|||||||
Assert.Single(overrides);
|
Assert.Single(overrides);
|
||||||
Assert.Equal("[]", overrides["Labels"]);
|
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