fix(siteruntime): normalize routed script return value for cross-process transport
A routed inbound-API call (Route.To(inst).Call(script)) runs the script on
the Site and returns its value to Central inside RouteToCallResponse, which
crosses the Central<->Site PROCESS boundary. A script's natural
'return new { ... }' is a compiler-generated anonymous type that Akka's
cross-process serializer cannot reconstruct on the receiving node, so the
reply was silently dropped and the caller's Route.To().Call() Ask timed out
at 30s with 'Script execution timed out' -- even though the script completed
and all device writes committed.
DeploymentManagerActor.RouteInboundApiCall now projects the routed return
value to a plain CLR graph (Dictionary/List/string/long/double/bool/null)
via a JSON round-trip before placing it in RouteToCallResponse. The graph
round-trips the wire and re-serializes to the same JSON shape the inbound
API expects for the HTTP body / ReturnDefinition validation.
Diagnosed live: IpsenMESMoveIn writes committed + site_events showed the
IpsenMoveIn script completed in ~0.6s, yet the inbound POST returned 500 at
30s; Central's Akka serializer logged 'Writing value of type
<>f__AnonymousType0`1 as Json' at the timeout moment.
379/379 SiteRuntime tests green.
This commit is contained in:
@@ -954,7 +954,18 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
var result = t.Result;
|
var result = t.Result;
|
||||||
return new RouteToCallResponse(
|
return new RouteToCallResponse(
|
||||||
request.CorrelationId, result.Success, result.ReturnValue,
|
request.CorrelationId, result.Success,
|
||||||
|
// The routed script's return value crosses the Central↔Site
|
||||||
|
// PROCESS boundary inside this response. A script's natural
|
||||||
|
// `return new { ... }` is an anonymous type, which Akka's
|
||||||
|
// cross-process serializer cannot round-trip — the reply is
|
||||||
|
// silently dropped and the caller's Route.To().Call() Ask
|
||||||
|
// times out even though the script succeeded. Project the
|
||||||
|
// value to a plain CLR graph (Dictionary/List/primitive) that
|
||||||
|
// round-trips the wire and reproduces the same JSON shape on
|
||||||
|
// the inbound side (which JsonSerializer-serializes it for the
|
||||||
|
// HTTP body / ReturnDefinition validation).
|
||||||
|
NormalizeRoutedReturnValue(result.ReturnValue),
|
||||||
result.ErrorMessage, DateTimeOffset.UtcNow);
|
result.ErrorMessage, DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
return new RouteToCallResponse(
|
return new RouteToCallResponse(
|
||||||
@@ -972,6 +983,59 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects a routed script's return value to a cross-process-serializable plain
|
||||||
|
/// CLR graph (<see cref="Dictionary{TKey,TValue}"/> / <see cref="List{T}"/> /
|
||||||
|
/// string / long / double / bool / null) via a JSON round-trip. A script's natural
|
||||||
|
/// <c>return new { ... }</c> is a compiler-generated anonymous type that Akka's
|
||||||
|
/// cross-process serializer cannot reconstruct on the receiving node, so the
|
||||||
|
/// <see cref="RouteToCallResponse"/> reply is silently dropped and the caller's
|
||||||
|
/// Ask times out. The projected graph round-trips the wire and re-serializes to the
|
||||||
|
/// same JSON shape the inbound API expects. Returns <paramref name="value"/>
|
||||||
|
/// unchanged on a (non-expected) serialization failure so a quirky value still has a
|
||||||
|
/// chance to deliver rather than being forced to null.
|
||||||
|
/// </summary>
|
||||||
|
private object? NormalizeRoutedReturnValue(object? value)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = System.Text.Json.JsonSerializer.Serialize(value);
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||||
|
return FromJsonElement(doc.RootElement);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Failed to normalize routed script return value of type {Type} for transport; sending as-is",
|
||||||
|
value.GetType().Name);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="System.Text.Json.JsonElement"/> to a plain CLR value
|
||||||
|
/// (string / long / double / bool / null, or nested Dictionary / List) — never a
|
||||||
|
/// <see cref="System.Text.Json.JsonElement"/> (which is itself not cross-process
|
||||||
|
/// serializable). Companion to <see cref="NormalizeRoutedReturnValue"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static object? FromJsonElement(System.Text.Json.JsonElement e) => e.ValueKind switch
|
||||||
|
{
|
||||||
|
System.Text.Json.JsonValueKind.Object =>
|
||||||
|
e.EnumerateObject().ToDictionary(p => p.Name, p => FromJsonElement(p.Value)),
|
||||||
|
System.Text.Json.JsonValueKind.Array =>
|
||||||
|
e.EnumerateArray().Select(FromJsonElement).ToList(),
|
||||||
|
System.Text.Json.JsonValueKind.String => e.GetString(),
|
||||||
|
System.Text.Json.JsonValueKind.Number => e.TryGetInt64(out var l) ? l : e.GetDouble(),
|
||||||
|
System.Text.Json.JsonValueKind.True => true,
|
||||||
|
System.Text.Json.JsonValueKind.False => false,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s)
|
/// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s)
|
||||||
/// call (or a central Test Run bound to the instance). Asks the Instance Actor
|
/// call (or a central Test Run bound to the instance). Asks the Instance Actor
|
||||||
|
|||||||
Reference in New Issue
Block a user