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?>();
|
var values = new Dictionary<string, object?>();
|
||||||
for (var i = 0; i < names.Count; i++)
|
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(
|
return new RouteToGetAttributesResponse(
|
||||||
request.CorrelationId, values, true, null, DateTimeOffset.UtcNow);
|
request.CorrelationId, values, true, null, DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,31 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
|||||||
return JsonSerializer.Serialize(config);
|
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>
|
/// <summary>
|
||||||
/// Builds a config carrying a single callable (no-trigger) script that
|
/// Builds a config carrying a single callable (no-trigger) script that
|
||||||
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
|
/// 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)));
|
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 ──
|
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user