fix(site-runtime): normalize routed GetAttributes List values for cross-process transport (#162)
This commit is contained in:
@@ -1206,7 +1206,18 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var values = new Dictionary<string, object?>();
|
||||
for (var i = 0; i < names.Count; i++)
|
||||
values[names[i]] = t.Result[i].Found ? t.Result[i].Value : null;
|
||||
// Each attribute value crosses the Central↔Site PROCESS boundary inside
|
||||
// this response. For a List-typed (static or coerced) attribute the value
|
||||
// is a concrete generic List<T> — a non-primitive shape Akka's cross-process
|
||||
// serializer cannot reliably round-trip, which would silently drop the reply
|
||||
// and hang the caller's Route.To().GetAttributes() Ask (same risk class as
|
||||
// RouteInboundApiCall / RouteInboundApiWaitForAttribute). Project each value
|
||||
// through the same normalizer so the wire carries a plain CLR graph
|
||||
// (Dictionary/List/primitive). Keys are preserved; scalars/strings/null pass
|
||||
// through unchanged.
|
||||
values[names[i]] = t.Result[i].Found
|
||||
? NormalizeRoutedReturnValue(t.Result[i].Value)
|
||||
: null;
|
||||
return new RouteToGetAttributesResponse(
|
||||
request.CorrelationId, values, true, null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,31 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a config carrying BOTH a static List attribute (whose JSON-array default
|
||||
/// the Instance Actor decodes into a typed <c>List<int></c>) and a static scalar
|
||||
/// attribute. Used to drive a routed <see cref="RouteToGetAttributesRequest"/> that
|
||||
/// exercises #162: the List value must be normalized for cross-process transport,
|
||||
/// while the scalar must pass through the normalizer unchanged.
|
||||
/// </summary>
|
||||
private static string MakeConfigWithListAndScalarAttributesJson(string instanceName)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Setpoints", Value = "[10,20,30]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
},
|
||||
new ResolvedAttribute { CanonicalName = "Mode", Value = "Auto", DataType = "String" }
|
||||
]
|
||||
};
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a config carrying a single callable (no-trigger) script that
|
||||
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
|
||||
@@ -515,6 +540,82 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
// ── #162: routed RouteToGetAttributesRequest → InstanceActor (List normalization) ──
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiGetAttributes_ListValuedAttribute_ReturnsSerializerSafeValueAndScalarUnchanged()
|
||||
{
|
||||
// #162: a routed GetAttributes read of a List-typed static attribute comes back
|
||||
// from the Instance Actor as a concrete generic List<int> — the SAME non-primitive
|
||||
// shape the cross-process (Newtonsoft) serializer cannot reliably round-trip that
|
||||
// WS-4 fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and
|
||||
// the caller's Route.To().GetAttributes() Ask hangs to timeout. The handler must
|
||||
// project EACH value through NormalizeRoutedReturnValue so the List survives the
|
||||
// wire — while leaving scalar/string/null values unchanged (no regression).
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500); // empty startup
|
||||
|
||||
// Static List attribute "Setpoints" = [10,20,30] (decoded to List<int>, Good)
|
||||
// alongside a scalar string attribute "Mode" = "Auto".
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-getattr-list", "GetAttrPumpList", "sha256:getattr-list",
|
||||
MakeConfigWithListAndScalarAttributesJson("GetAttrPumpList"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(1000); // let the InstanceActor spin up + decode the List default
|
||||
|
||||
// Request the List attribute, the scalar attribute, and a name that does not
|
||||
// exist (must come back null — the Found:false path) in one round-trip.
|
||||
actor.Tell(new RouteToGetAttributesRequest(
|
||||
"getattr-corr-list", "GetAttrPumpList",
|
||||
["Setpoints", "Mode", "DoesNotExist"], DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<RouteToGetAttributesResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("getattr-corr-list", response.CorrelationId);
|
||||
Assert.True(response.Success, $"Routed GetAttributes failed: {response.ErrorMessage}");
|
||||
|
||||
// Keys preserved: all three requested names present.
|
||||
Assert.True(response.Values.ContainsKey("Setpoints"));
|
||||
Assert.True(response.Values.ContainsKey("Mode"));
|
||||
Assert.True(response.Values.ContainsKey("DoesNotExist"));
|
||||
|
||||
// The List value must have been projected to a serializer-safe graph: a plain
|
||||
// List<object?> of primitives — NOT the Instance Actor's concrete List<int>
|
||||
// (which Akka's cross-process serializer drops). The JSON round-trip re-materializes
|
||||
// the numbers as boxed primitives; assert numeric equality without pinning the exact
|
||||
// CLR numeric type (long vs double is an artifact of the round-trip).
|
||||
var listValue = Assert.IsType<List<object?>>(response.Values["Setpoints"]);
|
||||
Assert.Equal(
|
||||
new[] { 10.0, 20.0, 30.0 },
|
||||
listValue.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
|
||||
|
||||
// Scalar/string value passes through the normalizer UNCHANGED (no regression).
|
||||
Assert.Equal("Auto", response.Values["Mode"]);
|
||||
|
||||
// A not-found attribute remains null (the Found:false path is untouched by
|
||||
// normalization — NormalizeRoutedReturnValue(null) is a no-op).
|
||||
Assert.Null(response.Values["DoesNotExist"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiGetAttributes_UnknownInstance_RepliesNotFound()
|
||||
{
|
||||
// A routed GetAttributes for an instance never deployed to this site must come
|
||||
// back Success:false with a not-found message (routing-level outcome), mirroring
|
||||
// the other RouteTo* unknown-instance paths.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500);
|
||||
|
||||
actor.Tell(new RouteToGetAttributesRequest(
|
||||
"getattr-corr-nf", "NeverDeployedGetAttr", ["Setpoints"], DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<RouteToGetAttributesResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("getattr-corr-nf", response.CorrelationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.Empty(response.Values);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user