merge(r2): r2-plan06

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Joseph Doherty
2026-07-13 11:09:24 -04:00
15 changed files with 599 additions and 55 deletions
@@ -166,7 +166,11 @@ public class ExternalSystemClient : IExternalSystemClient
/// Delivers a buffered ExternalSystem call during a store-and-forward
/// retry sweep. Returns true on success, false on permanent failure (the message
/// is parked); throws <see cref="TransientExternalSystemException"/> on a
/// transient failure so the engine retries.
/// transient failure so the engine retries. Deterministic-poison failures against
/// the CURRENT method definition — malformed JSON, or an
/// <see cref="ArgumentException"/> from an unresolvable <c>{param}</c> path
/// template or an unsupported HTTP verb — return false (park immediately) rather
/// than retry, because re-running the same stored payload can never succeed.
/// </summary>
/// <param name="message">The buffered message to deliver.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -221,6 +225,21 @@ public class ExternalSystemClient : IExternalSystemClient
_logger.LogError(ex, "Buffered call to '{System}' failed permanently; parking.", payload.SystemName);
return false;
}
catch (ArgumentException ex)
{
// N2: same poison-message shape as the JsonException catch above. BuildUrl's
// {param} path-template substitution (placeholder with no matching/non-null
// parameter) and ValidateHttpMethod both throw ArgumentException
// deterministically for the SAME stored payload — the method definition
// changed underneath the buffered call, and retrying can never succeed.
// Return false so the S&F engine parks the message instead of counting the
// throw as transient and burning MaxRetries.
_logger.LogError(
ex,
"Buffered call to '{System}'/'{Method}' fails deterministically against the current method definition; parking.",
payload.SystemName, payload.MethodName);
return false;
}
// TransientExternalSystemException propagates — the S&F engine retries.
}
@@ -447,6 +466,12 @@ public class ExternalSystemClient : IExternalSystemClient
/// into store-and-forward would defeat the cap. Cancellation
/// (<see cref="OperationCanceledException"/>) is allowed to propagate so the
/// caller's ordered timeout/cancellation catch filters classify it.
/// <para>
/// N4: the accumulated bytes are decoded using the response's declared charset
/// (<c>Content-Type: …; charset=…</c>) via <see cref="ResolveEncoding"/> — parity
/// with the pre-bounded-read <c>ReadAsStringAsync</c>. A null, empty, or
/// unrecognized charset falls back to UTF-8.
/// </para>
/// </summary>
private static async Task<string> ReadBodyBoundedAsync(
HttpContent content, string systemName, long maxBytes, CancellationToken token)
@@ -479,7 +504,33 @@ public class ExternalSystemClient : IExternalSystemClient
}
}
return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
// N4: honor the response's declared charset (parity with the pre-bounded-read
// ReadAsStringAsync). Unknown/invalid charset → UTF-8 fallback: mojibake beats
// failing the call over a cosmetic header.
return ResolveEncoding(content.Headers.ContentType?.CharSet)
.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
}
/// <summary>
/// Resolves the declared response charset to an <see cref="Encoding"/>, tolerating
/// the quoted form some servers emit (<c>charset="iso-8859-1"</c>). Null, empty,
/// or unrecognized values fall back to UTF-8.
/// </summary>
private static Encoding ResolveEncoding(string? charset)
{
if (string.IsNullOrWhiteSpace(charset))
{
return Encoding.UTF8;
}
try
{
return Encoding.GetEncoding(charset.Trim().Trim('"'));
}
catch (ArgumentException)
{
return Encoding.UTF8;
}
}
private static string Truncate(string value, int maxChars)
+6 -2
View File
@@ -94,8 +94,12 @@ try
builder.Services.AddTransport();
// Script-artifact invalidation bus: in-process, per-node pub/sub
// for ScriptArtifactsChanged. The Transport bundle importer publishes
// post-commit; the Inbound API compiled-handler cache subscribes as the
// consumer (wired in plan 06). Central-only — it lives beside the importer.
// post-commit; the Inbound API's ScriptArtifactChangeSubscriber (a hosted
// service registered by AddInboundAPI below) consumes ApiMethod notifications
// and invalidates the compiled-handler cache (wired in plan R2-06 T1; the
// per-request revision check remains the correctness fallback).
// SharedScript/Template notifications currently have NO consumer — nothing at
// central caches compiled artifacts of those kinds. Central-only.
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting.IScriptArtifactChangeBus,
ZB.MOM.WW.ScadaBridge.Host.Services.InProcessScriptArtifactChangeBus>();
@@ -179,12 +179,22 @@ public static class EndpointExtensions
httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
identity.DisplayName;
// N5: a body is present when Content-Length says so, OR when the request is
// chunked — Transfer-Encoding present and Content-Length (necessarily) absent.
// Computed once and used by BOTH the 415 guard and the JSON parse condition so
// chunked and fixed-length bodies behave identically.
var requestHeaders = httpContext.Request.Headers;
var hasBody = httpContext.Request.ContentLength > 0
|| (httpContext.Request.ContentLength is null && requestHeaders.TransferEncoding.Count > 0);
// S9: a body sent with an explicit non-JSON Content-Type is a media-type
// mismatch, not malformed JSON — reject it with 415 rather than a misleading
// 400 "invalid JSON". A body with NO Content-Type header is still parsed
// leniently as JSON below (preserving existing callers). The sniff is
// ordinal-ignore-case so "application/JSON" etc. are treated as JSON.
if (httpContext.Request.ContentLength > 0
// ordinal-ignore-case so "application/JSON" etc. are treated as JSON. N5: the
// body signal is `hasBody`, so a chunked (no Content-Length) non-JSON body is
// also caught here instead of slipping through to a misleading 400.
if (hasBody
&& !string.IsNullOrEmpty(httpContext.Request.ContentType)
&& httpContext.Request.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) == false)
{
@@ -203,7 +213,7 @@ public static class EndpointExtensions
// `Contains("json")` silently skipped JSON deserialization for any
// capitalised value, leaving `body = null` and surfacing required
// parameters as 400 "missing" even though the caller sent a valid body.
if (httpContext.Request.ContentLength > 0
if (hasBody
|| httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
using var doc = await JsonDocument.ParseAsync(
@@ -1,3 +1,4 @@
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -5,12 +6,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// C3: a routed call failed because the target site could not be reached (no contact /
/// unreachable). Derives from <see cref="InvalidOperationException"/> so existing
/// callers that catch the base type are unaffected, while
/// <see cref="InboundScriptExecutor"/> catches this more specific type to surface the
/// spec's <c>SITE_UNREACHABLE</c> error code instead of a generic <c>SCRIPT_ERROR</c>.
/// A routing failure whose message does <em>not</em> indicate unreachability stays a
/// C3/N3: a routed call failed because the target site never answered — the routed
/// Ask <b>expired</b>. Unreachability is typed at the Ask boundary from
/// <see cref="AskTimeoutException"/> (the communication layer's own signal: an
/// unreachable/contactless site has its enveloped message dropped so the Ask times
/// out caller-side) — it is <em>never</em> inferred from an error-message substring.
/// Derives from <see cref="InvalidOperationException"/> so existing callers that
/// catch the base type are unaffected, while <see cref="InboundScriptExecutor"/>
/// catches this more specific type to surface the spec's <c>SITE_UNREACHABLE</c>
/// error code instead of a generic <c>SCRIPT_ERROR</c>. A <c>Success=false</c>
/// <em>response</em> means the site answered (reachable by definition) and stays a
/// plain <see cref="InvalidOperationException"/>.
/// </summary>
public sealed class SiteUnreachableException : InvalidOperationException
@@ -18,6 +23,11 @@ public sealed class SiteUnreachableException : InvalidOperationException
/// <summary>Initializes a new <see cref="SiteUnreachableException"/> with the given message.</summary>
/// <param name="message">The routing-level failure message that indicated the site was unreachable.</param>
public SiteUnreachableException(string message) : base(message) { }
/// <summary>Initializes a new <see cref="SiteUnreachableException"/> wrapping the underlying Ask-timeout.</summary>
/// <param name="message">The routing-level failure message.</param>
/// <param name="innerException">The underlying <see cref="AskTimeoutException"/> from the routed Ask.</param>
public SiteUnreachableException(string message, Exception innerException) : base(message, innerException) { }
}
/// <summary>
@@ -182,7 +192,7 @@ public class RouteTarget
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
DateTimeOffset.UtcNow, _parentExecutionId);
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
var response = await RouteOrUnreachable(_instanceRouter.RouteToCallAsync(siteId, request, token));
if (!response.Success)
{
@@ -230,7 +240,7 @@ public class RouteTarget
correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow,
_parentExecutionId);
var response = await _instanceRouter.RouteToGetAttributesAsync(siteId, request, token);
var response = await RouteOrUnreachable(_instanceRouter.RouteToGetAttributesAsync(siteId, request, token));
if (!response.Success)
{
@@ -294,7 +304,7 @@ public class RouteTarget
AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow,
_parentExecutionId);
var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token);
var response = await RouteOrUnreachable(_instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token));
if (!response.Success)
{
@@ -345,7 +355,7 @@ public class RouteTarget
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow,
_parentExecutionId);
var response = await _instanceRouter.RouteToSetAttributesAsync(siteId, request, token);
var response = await RouteOrUnreachable(_instanceRouter.RouteToSetAttributesAsync(siteId, request, token));
if (!response.Success)
{
@@ -366,9 +376,9 @@ public class RouteTarget
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);
if (siteId == null)
{
// Route the site-resolution failure through the same classifier so a
// message indicating the site is unreachable surfaces as SITE_UNREACHABLE;
// a plain not-found / no-assigned-site stays a generic InvalidOperationException.
// A null site id is a plain not-found / no-assigned-site — never
// unreachability (nothing was routed), so it stays a generic
// InvalidOperationException (SCRIPT_ERROR).
throw RoutingFailure(
$"Instance '{_instanceCode}' not found or has no assigned site");
}
@@ -377,19 +387,37 @@ public class RouteTarget
}
/// <summary>
/// C3: classifies a routing-level failure message. When the message indicates the
/// site could not be reached (unreachable / no contact) the failure is a
/// <see cref="SiteUnreachableException"/> — so <see cref="InboundScriptExecutor"/>
/// can surface the spec's <c>SITE_UNREACHABLE</c> code — otherwise a plain
/// <see cref="InvalidOperationException"/>.
/// N3: awaits a routed Ask and TYPES unreachability. AskTimeoutException means
/// the site never answered (no ClusterClient contact, site down, partition) —
/// the communication layer's own typed signal (CentralCommunicationActor drops
/// envelopes for contactless sites so the Ask expires caller-side) — and
/// surfaces as SiteUnreachableException so the executor maps it to the spec's
/// SITE_UNREACHABLE code. A Success=false RESPONSE means the site answered and
/// is therefore reachable; it flows through RoutingFailure as a plain
/// InvalidOperationException (SCRIPT_ERROR).
/// </summary>
private static InvalidOperationException RoutingFailure(string message) =>
IsUnreachable(message)
? new SiteUnreachableException(message)
: new InvalidOperationException(message);
private static async Task<TResponse> RouteOrUnreachable<TResponse>(Task<TResponse> routedAsk)
{
try
{
return await routedAsk;
}
catch (AskTimeoutException ex)
{
throw new SiteUnreachableException(
"Site did not respond to the routed request within the routing timeout — unreachable or not connected.",
ex);
}
}
private static bool IsUnreachable(string message) =>
message.Contains("unreachable", StringComparison.OrdinalIgnoreCase)
|| message.Contains("no contact", StringComparison.OrdinalIgnoreCase)
|| message.Contains("no-contact", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// C3/N3: a routing-level failure REPORTED BY the site or the locator. The
/// remote end answered (or the instance simply has no site), so this is never
/// unreachability — always a plain <see cref="InvalidOperationException"/>
/// (SCRIPT_ERROR at the endpoint). Unreachability is typed at the Ask boundary
/// by <c>RouteOrUnreachable</c> (AskTimeoutException → SiteUnreachableException),
/// replacing the old message-substring sniff that a harmless rewording in the
/// communication layer could silently defeat.
/// </summary>
private static InvalidOperationException RoutingFailure(string message) => new(message);
}
@@ -0,0 +1,87 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// N1 (arch-review 06 round 2): the Inbound API consumer of the script-artifact
/// invalidation contract (docs/plans/2026-07-08-script-artifact-invalidation-contract.md,
/// handoff item (a)). Subscribes to <see cref="IScriptArtifactChangeBus"/> on host
/// start and, for every <c>ApiMethod</c> notification (e.g. a Transport bundle
/// import overwriting method scripts, published post-commit), drops the compiled
/// handler AND the known-bad record via
/// <see cref="InboundScriptExecutor.InvalidateMethod"/>, so the next request
/// recompiles from the fresh row without waiting for the per-request
/// revision-check self-heal.
/// <para>
/// Fast-path only: the revision check in <c>ExecuteAsync</c> remains the
/// correctness fallback — this bus is in-process/per-node and at-least-once
/// (contract §4), so a missed or duplicate notification must always be harmless.
/// The bus is optional (site roles and plain test hosts register none) — the
/// subscriber then no-ops. SharedScript/Template notifications are ignored: this
/// executor caches only ApiMethod handlers.
/// </para>
/// </summary>
public sealed class ScriptArtifactChangeSubscriber : IHostedService
{
private readonly InboundScriptExecutor _executor;
private readonly ILogger<ScriptArtifactChangeSubscriber> _logger;
private readonly IScriptArtifactChangeBus? _bus;
private IDisposable? _subscription;
/// <summary>Initializes the subscriber.</summary>
/// <param name="executor">The compiled-handler cache to invalidate.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="bus">The change bus, or null when the host registers none (site roles, tests).</param>
public ScriptArtifactChangeSubscriber(
InboundScriptExecutor executor,
ILogger<ScriptArtifactChangeSubscriber> logger,
IScriptArtifactChangeBus? bus = null)
{
_executor = executor;
_logger = logger;
_bus = bus;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
if (_bus is null)
{
_logger.LogDebug(
"No IScriptArtifactChangeBus registered; inbound handler-cache freshness relies on the per-request revision check alone.");
return Task.CompletedTask;
}
_subscription = _bus.Subscribe(OnChanged);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_subscription?.Dispose();
_subscription = null;
return Task.CompletedTask;
}
private void OnChanged(ScriptArtifactsChanged notification)
{
if (!string.Equals(
notification.ArtifactKind, ScriptArtifactKinds.ApiMethod, StringComparison.Ordinal))
{
return; // only ApiMethod handlers are cached by this executor
}
foreach (var name in notification.Names)
{
_executor.InvalidateMethod(name);
}
_logger.LogInformation(
"Invalidated {Count} inbound API method handler(s) after a {Source} script-artifact change.",
notification.Names.Count, notification.Source);
}
}
@@ -27,6 +27,12 @@ public static class ServiceCollectionExtensions
// body size cap and active-node gating for POST /api/{methodName}.
services.AddSingleton<InboundApiEndpointFilter>();
// N1: the ApiMethod consumer of the script-artifact invalidation bus.
// The bus parameter is optional — hosts without a registered
// IScriptArtifactChangeBus (site roles, plain test hosts) start it as a no-op
// and the executor's per-request revision check alone keeps handlers fresh.
services.AddHostedService<ScriptArtifactChangeSubscriber>();
return services;
}
}