docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
Comment/XML-doc changes only, no code logic: - strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from comments, keeping the explanatory rationale - add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/> - replace misused <inheritdoc/> with real docs; where <inheritdoc/> was combined with useful remarks, move the rationale into // comments - drop stale replicationActor <param> on DeploymentManagerActor ctor Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365). Build 0 errors / 0 warnings.
This commit is contained in:
@@ -227,7 +227,7 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
projected.Add(AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc));
|
||||
}
|
||||
|
||||
// ONE set-based statement for the whole packet (WP2.2) instead of one
|
||||
// ONE set-based statement for the whole packet instead of one
|
||||
// IF NOT EXISTS … INSERT round trip per event. A telemetry packet of 200
|
||||
// rows used to cost 200 sequential round trips on the central singleton's
|
||||
// dispatcher; it now costs two. Idempotency is unchanged — duplicates
|
||||
@@ -356,7 +356,7 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
|
||||
// Fast path (WP2.2): the WHOLE packet in ONE transaction — one
|
||||
// Fast path: the WHOLE packet in ONE transaction — one
|
||||
// set-based audit insert plus one single-statement upsert per entry.
|
||||
// The predecessor opened a transaction PER entry and issued three
|
||||
// statements inside it (IF NOT EXISTS insert, then the two-statement
|
||||
|
||||
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// than <c>DateTime.UtcNow - RetentionDays</c>.</item>
|
||||
/// <item>For each eligible boundary, calls
|
||||
/// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>, a
|
||||
/// metadata-only staging-table switch (WP2.2 removed the index
|
||||
/// drop/rebuild that used to bracket it).</item>
|
||||
/// metadata-only staging-table switch (no index
|
||||
/// drop/rebuild brackets it).</item>
|
||||
/// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system
|
||||
/// EventStream so the central health collector + ops surfaces
|
||||
/// can subscribe without coupling to this actor.</item>
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The purge actor is a daily-cadence singleton, not a hot-loop. The
|
||||
/// partition switch itself is metadata-only (since WP2.2's
|
||||
/// <c>AlignAuditLogEventIdUniqueness</c> it no longer drops and rebuilds a
|
||||
/// partition switch itself is metadata-only (since the
|
||||
/// <c>AlignAuditLogEventIdUniqueness</c> migration it no longer drops and rebuilds a
|
||||
/// non-aligned unique index around it), but it still takes schema-modification
|
||||
/// locks and competes with ingest for the same table. Lower this only when an
|
||||
/// operator can prove they need sub-daily purge granularity.
|
||||
|
||||
@@ -28,6 +28,9 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
|
||||
private readonly ILogger<GrpcPullTraceEventsClient> _logger;
|
||||
|
||||
/// <summary>Creates the client over the given site enumerator and unary-call invoker.</summary>
|
||||
/// <param name="sites">Enumerates the sites and their gRPC endpoints.</param>
|
||||
/// <param name="invoker">Issues the unary <c>PullTraceEvents</c> call.</param>
|
||||
/// <param name="logger">Logger for pull diagnostics.</param>
|
||||
public GrpcPullTraceEventsClient(
|
||||
ISiteEnumerator sites,
|
||||
IPullTraceEventsInvoker invoker,
|
||||
@@ -152,6 +155,11 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
|
||||
public interface IPullTraceEventsInvoker
|
||||
{
|
||||
/// <summary>Issues the RPC against <paramref name="endpoint"/> with the site's preshared key.</summary>
|
||||
/// <param name="siteId">The site the call targets.</param>
|
||||
/// <param name="endpoint">The site node gRPC endpoint to call.</param>
|
||||
/// <param name="request">The pull request.</param>
|
||||
/// <param name="ct">Token to cancel the call.</param>
|
||||
/// <returns>The site's trace-event response.</returns>
|
||||
Task<PullTraceEventsResponse> InvokeAsync(
|
||||
string siteId, string endpoint, PullTraceEventsRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -169,6 +177,8 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
|
||||
private readonly ISitePskProvider? _pskProvider;
|
||||
|
||||
/// <summary>Creates the invoker with the given options and optional per-site PSK provider.</summary>
|
||||
/// <param name="options">Communication options supplying keepalive settings.</param>
|
||||
/// <param name="pskProvider">Optional provider of per-site preshared keys.</param>
|
||||
public GrpcPullTraceEventsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
@@ -212,7 +222,7 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
|
||||
},
|
||||
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes every cached gRPC channel.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var channel in _channels.Values)
|
||||
|
||||
@@ -521,6 +521,8 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
||||
/// lost rows get distinct synthetic ids.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="lostEventId">The event id of the lost row being abandoned.</param>
|
||||
/// <returns>A deterministic synthetic GUID for the abandonment row.</returns>
|
||||
internal static Guid DeriveAbandonmentEventId(Guid lostEventId)
|
||||
{
|
||||
Span<byte> input = stackalloc byte[32];
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the options-change registration, if any.</summary>
|
||||
public void Dispose() => _optionsReload?.Dispose();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -174,7 +174,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
pragmaCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// synchronous=NORMAL (arch-review remediation WP1.2): SQLite's FULL default
|
||||
// synchronous=NORMAL: SQLite's FULL default
|
||||
// fsyncs on every transaction commit, which is the dominant cost of the
|
||||
// per-event write path this same change coalesces via FlushIntervalMs — FULL
|
||||
// would still fsync once per FLUSH even after that fix. NORMAL fsyncs at
|
||||
@@ -346,8 +346,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// burst of concurrent enqueues. Once the immediately-available
|
||||
// burst is drained, keep the transaction open for up to
|
||||
// FlushIntervalMs waiting for MORE events to trickle in — this is
|
||||
// the soft-flush coalescing arch-review remediation WP1.2 adds:
|
||||
// without it, a low-rate trickle of writes (one script call every
|
||||
// the soft-flush coalescing
|
||||
// this loop provides: without it, a low-rate trickle of writes (one script call every
|
||||
// few ms, well under BatchSize) fsyncs once PER EVENT, because the
|
||||
// writer loop reaches an empty channel and flushes immediately.
|
||||
// FlushIntervalMs bounds the worst-case added latency any single
|
||||
@@ -392,8 +392,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Total number of <see cref="FlushBatch"/> transactions committed since
|
||||
/// construction. Test-only observability seam (arch-review remediation
|
||||
/// WP1.2) for asserting that FlushIntervalMs coalesces a trickle of
|
||||
/// construction. Test-only observability seam for asserting that FlushIntervalMs coalesces a trickle of
|
||||
/// near-simultaneous writes into a single transaction rather than one
|
||||
/// commit per event; production code never reads this.
|
||||
/// </summary>
|
||||
@@ -499,8 +498,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
{
|
||||
eventCmd.ExecuteNonQuery();
|
||||
fwdCmd.ExecuteNonQuery();
|
||||
// Do NOT complete pending.Completion here (arch-review 2026-08-19
|
||||
// P4-2). A row's INSERTs succeeding is not durable until the
|
||||
// Do NOT complete pending.Completion here. A row's INSERTs succeeding is not durable until the
|
||||
// transaction actually commits below — completing the caller's Task
|
||||
// this early means a later commit failure (SQLITE_FULL, IOERR, or a
|
||||
// later row in this same batch throwing) reports false success: the
|
||||
@@ -866,7 +864,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
/// <summary>Flow Debugger PullTraceEvents: hard cap on ids per read (design §5.4).</summary>
|
||||
public const int MaxExecutionIdsPerRead = 64;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Reads audit events belonging to any of the given execution ids, newer than <paramref name="sinceUtc"/>, in keyset-paged order.
|
||||
/// </summary>
|
||||
/// <param name="executionIds">Execution ids to match (capped at <see cref="MaxExecutionIdsPerRead"/>).</param>
|
||||
/// <param name="sinceUtc">Lower bound on the event's occurrence time.</param>
|
||||
/// <param name="limit">Maximum number of events to return; must be positive.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="afterEventId">Optional keyset cursor; only events after this id are returned.</param>
|
||||
/// <returns>The matching audit events.</returns>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||
{
|
||||
@@ -925,6 +931,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
/// UNION deduplicates rows matching both IDs. Keeping payloads outside the union bounds sorting
|
||||
/// to IDs/timestamps and avoids the optimizer preferring a whole-store timestamp scan.
|
||||
/// </summary>
|
||||
/// <param name="inList">The pre-built SQL parameter list for the <c>IN (...)</c> clauses.</param>
|
||||
/// <returns>The trace-read SQL text.</returns>
|
||||
internal static string BuildTraceReadSql(string inList) => $"""
|
||||
SELECT e.EventId, e.OccurredAtUtc, e.Actor, e.Action, e.Outcome,
|
||||
e.Category, e.Target, e.SourceNode, e.CorrelationId, e.DetailsJson
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SqliteAuditWriterOptions.DatabasePath"/> is deliberately NOT
|
||||
/// validated here (arch-review remediation WP1.2). <c>AddAuditLog</c> runs on
|
||||
/// validated here. <c>AddAuditLog</c> runs on
|
||||
/// BOTH Central and Site composition roots and binds this options type with
|
||||
/// <c>ValidateOnStart</c> either way, but only Site nodes ever resolve
|
||||
/// <see cref="SqliteAuditWriter"/> — requiring a non-empty path in this
|
||||
|
||||
@@ -17,6 +17,8 @@ internal static class SqliteErrorCodes
|
||||
/// "primary/extended" (e.g. "10/522") for a <see cref="SqliteException"/>
|
||||
/// anywhere in the exception chain; "n/a" for non-SQLite failures.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to inspect, including its inner exceptions.</param>
|
||||
/// <returns>The "primary/extended" SQLite error code string, or "n/a" for non-SQLite failures.</returns>
|
||||
public static string Describe(Exception ex)
|
||||
{
|
||||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||||
|
||||
@@ -220,8 +220,8 @@ public static class AuditCommands
|
||||
|
||||
// Ctrl-C is the one way to abort an in-flight export other than the
|
||||
// headers/inactivity deadlines below — mirrors the CancellationTokenSource
|
||||
// owned by DebugCommands' streaming debug-view command (arch-review
|
||||
// 2026-08-19 P6-8: this was the one CLI HTTP path with NO deadline at all).
|
||||
// owned by DebugCommands' streaming debug-view command (this was the
|
||||
// one CLI HTTP path with NO deadline at all).
|
||||
using var cts = new CancellationTokenSource();
|
||||
ConsoleCancelEventHandler onCancel = (_, e) =>
|
||||
{
|
||||
|
||||
@@ -115,7 +115,7 @@ public static class AuditExportHelpers
|
||||
/// and <see cref="SocketsHttpHandler.ConnectTimeout"/> bounds only the TCP/TLS
|
||||
/// connect phase, not a connected-but-silent server. Reuses
|
||||
/// <see cref="ManagementHttpClient.DefaultConnectTimeout"/> as the standard CLI
|
||||
/// per-call default (arch-review 2026-08-19 P6-8).
|
||||
/// per-call default.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultHeadersTimeout = ManagementHttpClient.DefaultConnectTimeout;
|
||||
|
||||
@@ -126,7 +126,7 @@ public static class AuditExportHelpers
|
||||
/// hung/crashed server holding the connection open) is bounded rather than
|
||||
/// hanging the CLI forever — the residual gap the timeout-honesty fix
|
||||
/// (<c>ManagementHttpClient.DefaultConnectTimeout</c> doc comment) left in this
|
||||
/// one path (arch-review 2026-08-19 P6-8).
|
||||
/// one path.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultInactivityTimeout = ManagementHttpClient.DefaultConnectTimeout;
|
||||
|
||||
@@ -204,7 +204,7 @@ public static class AuditExportHelpers
|
||||
// Honour the documented "authorization failure → exit 2"
|
||||
// contract on the REST audit surface as well. HTTP 403 is the
|
||||
// primary signal; the server may also surface UNAUTHORIZED /
|
||||
// FORBIDDEN via the JSON error envelope on a non-403 status.
|
||||
// FORBIDDEN via the JSON error envelope on a status other than 403.
|
||||
var errorCode = TryExtractErrorCode(message);
|
||||
var isAuthFailure = (int)response.StatusCode == 403
|
||||
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -404,7 +404,7 @@ public static class BundleCommands
|
||||
// RunBundleCommandAsync that re-implemented URL/credential resolution and
|
||||
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
|
||||
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
|
||||
// code on a non-403 status would exit 1 instead of the documented exit 2.
|
||||
// code on a status other than 403 would exit 1 instead of the documented exit 2.
|
||||
// The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with
|
||||
// the longer BundleCommandTimeout and a per-command success handler, so the
|
||||
// exit-code contract is unified across every command group.
|
||||
|
||||
@@ -262,7 +262,7 @@ internal static class CommandHelpers
|
||||
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
|
||||
|
||||
// A "note" property that appears ONLY on single-property rows (a trailing
|
||||
// run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception
|
||||
// run-level note, or a step's folded log/exception
|
||||
// line) is not a real column: every other row's cell there would render blank,
|
||||
// and column padding would swallow the raw line's own leading indentation. Such
|
||||
// rows print as a raw unpadded line instead, at their position in the sequence,
|
||||
|
||||
@@ -96,6 +96,10 @@ public static class DeployCommands
|
||||
/// whose summaries all carry <c>failureCount</c> — a per-site failure is a successful HTTP
|
||||
/// call, so without this the process exited 0 and scripts could not detect it.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON response body from the management API.</param>
|
||||
/// <param name="format">The output format (e.g. json or table).</param>
|
||||
/// <param name="summaryName">Display name for the summary, used in the invalid-response error.</param>
|
||||
/// <returns>0 when nothing failed; 1 when any failure was reported or the summary was invalid.</returns>
|
||||
internal static int HandleSiteDeploymentResponse(string json, string format, string summaryName = "site deployment")
|
||||
{
|
||||
CommandHelpers.HandleResponse(new ManagementResponse(200, json, null, null), format);
|
||||
|
||||
@@ -215,6 +215,8 @@ public static class FlowDebugCommands
|
||||
/// means "no parameters". Throws <see cref="JsonException"/> on malformed input or a
|
||||
/// non-object root (the inbound API body is always a flat JSON object).
|
||||
/// </summary>
|
||||
/// <param name="json">The raw <c>--params</c> JSON text, or null/blank for none.</param>
|
||||
/// <returns>The parsed root object, or null when no parameters were supplied.</returns>
|
||||
internal static JsonElement? ParseParameters(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
||||
@@ -45,7 +45,7 @@ internal static class FlowDebugTableProjection
|
||||
/// <param name="showLogs">
|
||||
/// When true, prints each step's <c>ScriptLog</c> lines and (on a failed step) the first
|
||||
/// three lines of its exception detail as additional indented <c>note</c> rows immediately
|
||||
/// following that step's row (step-logs design 2026-09-09, CLI Task 14). The <c>logs</c>
|
||||
/// following that step's row. The <c>logs</c>
|
||||
/// column (a count of attached log lines) is always present, independent of this flag.
|
||||
/// </param>
|
||||
/// <returns>Compact JSON suitable for table rendering.</returns>
|
||||
@@ -103,7 +103,7 @@ internal static class FlowDebugTableProjection
|
||||
if (truncated)
|
||||
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
|
||||
|
||||
// Final review M8: the page shows this; the CLI silently dropped it.
|
||||
// The page shows this; the CLI silently dropped it.
|
||||
if (logsTruncated)
|
||||
notes.Add("inline log lines capped — replay pulls the rest");
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ public class ManagementHttpClient : IDisposable
|
||||
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
// Test seam (WP2.6e): exposes the constructed HttpClient's effective Timeout
|
||||
// Test seam: exposes the constructed HttpClient's effective Timeout
|
||||
// without requiring reflection.
|
||||
EffectiveTimeout = httpClient.Timeout;
|
||||
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
|
||||
@@ -36,8 +36,7 @@ public static class OutputFormatter
|
||||
/// <summary>
|
||||
/// One row for the <see cref="TableRow"/> overload of <c>WriteTable</c>: either padded
|
||||
/// column cells like the plain <see cref="string[]"/> overload, or an already-formatted
|
||||
/// line printed verbatim with no column padding/alignment at all (Task 14 review,
|
||||
/// 2026-09-09) — used for a note/log line folded onto a step, which must render as a raw
|
||||
/// line printed verbatim with no column padding/alignment at all — used for a note/log line folded onto a step, which must render as a raw
|
||||
/// indented line under that step's row rather than as a cell in a table column (every
|
||||
/// other row's cell there would otherwise render blank, and the line's own leading
|
||||
/// whitespace would be swallowed by column padding).
|
||||
@@ -50,14 +49,20 @@ public static class OutputFormatter
|
||||
RawLine = rawLine;
|
||||
}
|
||||
|
||||
/// <summary>The column cells of a normal row, or null for a raw line.</summary>
|
||||
internal string[]? Cells { get; }
|
||||
|
||||
/// <summary>The verbatim line of a raw row, or null for a column row.</summary>
|
||||
internal string? RawLine { get; }
|
||||
|
||||
/// <summary>A normal row, rendered as padded columns aligned with the header set.</summary>
|
||||
/// <param name="cells">The column cell values.</param>
|
||||
/// <returns>A column row.</returns>
|
||||
internal static TableRow Columns(string[] cells) => new(cells, null);
|
||||
|
||||
/// <summary>An already-formatted line, printed exactly as given with no column alignment.</summary>
|
||||
/// <param name="line">The preformatted line.</param>
|
||||
/// <returns>A raw row.</returns>
|
||||
internal static TableRow Raw(string line) => new(null, line);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker;
|
||||
|
||||
internal static class WorkerProgram
|
||||
{
|
||||
/// <summary>Runs the sandbox worker protocol loop until the host closes it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public static Task Main() => ScriptAnalysis.SandboxWorkerProtocol.RunWorkerAsync();
|
||||
}
|
||||
|
||||
@@ -228,6 +228,11 @@ public static class AuthEndpoints
|
||||
}
|
||||
|
||||
/// <summary>Records real browser input only after authenticating and validating antiforgery.</summary>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <param name="antiforgery">Antiforgery service used to validate the request.</param>
|
||||
/// <param name="tracker">Tracker that records the session activity.</param>
|
||||
/// <param name="clock">Time source for the activity timestamp.</param>
|
||||
/// <returns>An HTTP result indicating whether the activity was recorded.</returns>
|
||||
public static async Task<IResult> HandleActivity(HttpContext context, IAntiforgery antiforgery,
|
||||
SessionActivityTracker tracker, TimeProvider clock)
|
||||
{
|
||||
|
||||
@@ -13,11 +13,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary>
|
||||
/// <param name="provider">The authentication state provider.</param>
|
||||
/// <returns>The authoritative authentication state.</returns>
|
||||
public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider)
|
||||
=> provider is CookieAuthenticationStateProvider cookie
|
||||
? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync();
|
||||
|
||||
/// <summary>Checks the current grant before invoking a directly audited application service.</summary>
|
||||
/// <param name="provider">The authentication state provider.</param>
|
||||
/// <param name="role">The role the current session must hold.</param>
|
||||
/// <returns>The username of the authorized principal.</returns>
|
||||
public static async Task<string> RequireRoleAsync(this AuthenticationStateProvider provider, string role)
|
||||
{
|
||||
var principal = (await provider.GetAuthoritativeStateAsync()).User;
|
||||
|
||||
@@ -23,6 +23,9 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
|
||||
/// <param name="httpContextAccessor">Supplies the user captured at construction.</param>
|
||||
/// <param name="scopes">Factory for fresh scopes used during revalidation.</param>
|
||||
/// <param name="autoLogin">Options indicating whether login is disabled (auto-login).</param>
|
||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null)
|
||||
{
|
||||
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
@@ -36,6 +39,7 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
|
||||
|
||||
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
|
||||
/// <returns>The authoritative authentication state.</returns>
|
||||
public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true);
|
||||
|
||||
private async Task<AuthenticationState> RefreshAsync(bool failClosed)
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class BrowserTime
|
||||
/// <param name="browserUtcOffsetMinutes">
|
||||
/// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number
|
||||
/// of minutes that, <b>added</b> to local time, yields UTC. It is positive
|
||||
/// for time zones behind UTC (e.g. +300 for UTC-5) and negative for zones
|
||||
/// for time zones behind UTC (e.g. +300 for five hours behind UTC) and negative for zones
|
||||
/// ahead (e.g. -120 for UTC+2).
|
||||
/// </param>
|
||||
/// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns>
|
||||
|
||||
+14
-7
@@ -79,7 +79,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
||||
/// <summary>
|
||||
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
|
||||
/// escape hatch validates against the same resolved shape the form renders (#37).
|
||||
/// escape hatch validates against the same resolved shape the form renders.
|
||||
/// </summary>
|
||||
[Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!;
|
||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||
@@ -154,7 +154,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private bool _scriptParamsValid;
|
||||
|
||||
/// <summary>
|
||||
/// Method entry (#37): the chosen method's resolved root schema (null when it declares no
|
||||
/// Method entry: the chosen method's resolved root schema (null when it declares no
|
||||
/// parameters or the definitions do not parse) and its top-level fields — the card renders
|
||||
/// only when there is at least one. The values live in <see cref="_methodParams"/>, the same
|
||||
/// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request
|
||||
@@ -166,7 +166,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private bool _methodParamsValid;
|
||||
|
||||
/// <summary>
|
||||
/// Raw-JSON escape hatch for the whole method payload (#37): paste the exact body an
|
||||
/// Raw-JSON escape hatch for the whole method payload: paste the exact body an
|
||||
/// external caller would send. The text is parsed and schema-validated on every edit;
|
||||
/// only a valid document reaches <see cref="_methodParams"/>, which stays the single source
|
||||
/// of truth in both modes.
|
||||
@@ -214,6 +214,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// on a step are NOT here — they are reported separately as
|
||||
/// <see cref="FlowGraph.UnattachedLogLines"/>.
|
||||
/// </summary>
|
||||
/// <param name="graph">The flow graph to count log lines in, or null.</param>
|
||||
/// <returns>The error, warning, info and total log-line counts.</returns>
|
||||
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
|
||||
{
|
||||
if (graph is null)
|
||||
@@ -706,7 +708,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
// row the run did not already have, the page is no longer short of lines and the flag
|
||||
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
|
||||
// 5-30 s later, so a refresh that adds nothing proves nothing.
|
||||
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's
|
||||
// Read the OUTCOME's own flag — FlowDebugService copies the raw result's
|
||||
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
|
||||
// member with no consumer.
|
||||
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
|
||||
@@ -721,6 +723,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
|
||||
|
||||
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
|
||||
/// <param name="known">Events already held by the page, or null.</param>
|
||||
/// <param name="fresh">Newly read Replay events, or null.</param>
|
||||
/// <returns>The merged event list.</returns>
|
||||
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
|
||||
{
|
||||
var byId = new Dictionary<Guid, AuditEvent>();
|
||||
@@ -852,7 +857,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>Cancels any pending outcome refresh when the component is disposed.</summary>
|
||||
public void Dispose() => CancelPendingRefresh();
|
||||
|
||||
private async Task LoadReplayAsync()
|
||||
@@ -972,7 +977,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Form ⇄ Raw JSON (#37). Entering raw mode seeds the editor from the current value tree so
|
||||
/// Form ⇄ Raw JSON. Entering raw mode seeds the editor from the current value tree so
|
||||
/// the operator edits what the form would have sent; leaving it re-mounts the form over the
|
||||
/// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is
|
||||
/// on, so it mounts fresh and re-reports validity from the tree).
|
||||
@@ -1054,6 +1059,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method
|
||||
/// declares no parameters. Run is gated on validity, so nothing here re-checks the tree.
|
||||
/// </summary>
|
||||
/// <returns>The serialized parameters, or null when the method declares none.</returns>
|
||||
internal JsonElement? BuildParameters()
|
||||
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
|
||||
|
||||
@@ -1062,6 +1068,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
|
||||
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
|
||||
/// </summary>
|
||||
/// <returns>The serialized parameters, or null when the form produced nothing.</returns>
|
||||
internal JsonElement? BuildScriptParameters()
|
||||
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
|
||||
|
||||
@@ -1092,7 +1099,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Restores a persisted value tree only when it still validates against the method's current
|
||||
/// schema — a stale tree (the schema changed, or the pre-#37 flat string-per-field entry) is
|
||||
/// schema — a stale tree (the schema changed, or the older flat string-per-field entry) is
|
||||
/// dropped rather than painted into the form as errors.
|
||||
/// </summary>
|
||||
private async Task RestoreInputsAsync(int methodId)
|
||||
|
||||
+4
@@ -123,6 +123,10 @@ public partial class InstanceConfigure
|
||||
/// counts: it pins a reportable template alarm off for this instance, which is a
|
||||
/// real override and not the same as inheriting.
|
||||
/// </summary>
|
||||
/// <param name="overrideJson">The override JSON entered in the modal, if any.</param>
|
||||
/// <param name="priority">The priority override, if one was chosen.</param>
|
||||
/// <param name="mesReportable">The MES-reportable override, if one was chosen.</param>
|
||||
/// <returns><c>true</c> when the modal carries any override worth persisting; otherwise <c>false</c>.</returns>
|
||||
internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
|
||||
=> !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
|
||||
|
||||
|
||||
+5
-5
@@ -112,7 +112,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Guards <see cref="_session"/> together with <see cref="_disposed"/> (register row
|
||||
/// 103, the P6-6 lesser instance — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
|
||||
/// 103 — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
|
||||
/// <see cref="TryLoadAsync"/> awaits <see cref="IBundleImporter.LoadAsync"/>, and the
|
||||
/// circuit can be torn down inside that window; publishing the freshly-opened session
|
||||
/// and observing disposal must be one atomic step so exactly one side (this method or
|
||||
@@ -258,7 +258,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
var loaded = await BundleImporter.LoadAsync(
|
||||
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
|
||||
|
||||
// Register row 103 (P6-6 lesser instance): the component may have been
|
||||
// Register row 103: the component may have been
|
||||
// disposed while LoadAsync was in flight (navigation away mid-upload).
|
||||
// Publish and re-check disposal atomically under _sessionLock so exactly
|
||||
// one side tears the freshly-opened session down.
|
||||
@@ -278,9 +278,9 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
|
||||
// Encrypted bundle, no passphrase supplied — caller advances to the passphrase step.
|
||||
// We deliberately do NOT set _errorMessage here; the page surfaces
|
||||
// an empty Step-2 prompt instead.
|
||||
// an empty passphrase prompt instead.
|
||||
_session = null;
|
||||
throw;
|
||||
}
|
||||
@@ -977,7 +977,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
/// Register row 103: the flag is set and the session taken under the same lock
|
||||
/// <see cref="TryLoadAsync"/> publishes through, so a <see cref="TryLoadAsync"/> still
|
||||
/// awaiting <see cref="IBundleImporter.LoadAsync"/> either hands the session over here
|
||||
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s P6-6 fix).
|
||||
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s fix).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -138,6 +138,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
}
|
||||
|
||||
/// <summary>Formats a coordinate culture-invariantly.</summary>
|
||||
/// <param name="v">The value to format.</param>
|
||||
/// <returns>The value formatted with up to two decimals using the invariant culture.</returns>
|
||||
public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom));
|
||||
@@ -199,6 +201,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
=> n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
|
||||
|
||||
/// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary>
|
||||
/// <param name="outcome">The folded outcome of the outbound step.</param>
|
||||
/// <returns>The short pill label for the outcome.</returns>
|
||||
public static string PillText(FlowStepOutcome outcome) => outcome switch
|
||||
{
|
||||
FlowStepOutcome.Ok => "Ok",
|
||||
@@ -224,6 +228,9 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// sync database call's operation detail (<c>write · 3 rows</c>) kept in front of it. The projector's
|
||||
/// subtitle already leads with the audit status, which the pill now carries, so that head is dropped.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of the outbound step.</param>
|
||||
/// <param name="subtitle">The step's projected subtitle, which may lead with the audit status.</param>
|
||||
/// <returns>The mode text shown after the status pill.</returns>
|
||||
public static string OutboundMode(FlowStepKind kind, string? subtitle)
|
||||
{
|
||||
if (kind == FlowStepKind.Notify) return "notify";
|
||||
@@ -243,6 +250,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
|
||||
/// never silently drops a line.
|
||||
/// </summary>
|
||||
/// <param name="s">The step whose log lines are counted; may be null.</param>
|
||||
/// <returns>The error, warning and info line counts.</returns>
|
||||
internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s)
|
||||
{
|
||||
if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0);
|
||||
@@ -260,6 +269,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
}
|
||||
|
||||
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
|
||||
/// <param name="counts">The per-severity log line counts.</param>
|
||||
/// <returns>The hover text listing the non-zero levels.</returns>
|
||||
internal static string LogTitle((int Errors, int Warnings, int Info) counts)
|
||||
{
|
||||
var (errors, warnings, info) = counts;
|
||||
@@ -273,12 +284,14 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// <summary>
|
||||
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
|
||||
/// the type is the exception's name without its namespace. Parsing is
|
||||
/// <see cref="ExceptionHeader.Parse"/> (final review M1 — the same parser the step detail panel
|
||||
/// <see cref="ExceptionHeader.Parse"/> (the same parser the step detail panel
|
||||
/// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows
|
||||
/// its <c>" ---> "</c> chain to the innermost exception. Falls back to the line itself when it
|
||||
/// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail.
|
||||
/// Null for every non-failed step.
|
||||
/// </summary>
|
||||
/// <param name="s">The step to build the subtitle for.</param>
|
||||
/// <returns>The exception subtitle, or null for a step that did not fail.</returns>
|
||||
internal static string? ExceptionSubtitle(FlowStep s)
|
||||
{
|
||||
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
|
||||
@@ -305,6 +318,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// than one row — the submit→terminal wall time with a leading <c>+</c> (<c>+4.1 s</c>), since that
|
||||
/// span is elapsed time downstream of the script, not time the script spent (node anatomy).
|
||||
/// </summary>
|
||||
/// <param name="n">The layout node to describe.</param>
|
||||
/// <returns>The formatted duration text for the node's head row.</returns>
|
||||
public static string Duration(FlowLayoutNode n)
|
||||
{
|
||||
if (n.Step?.DurationMs is not int ms)
|
||||
@@ -317,6 +332,9 @@ public partial class FlowGraphView : ComponentBase
|
||||
}
|
||||
|
||||
/// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary>
|
||||
/// <param name="text">The label text; may be null.</param>
|
||||
/// <param name="max">The maximum number of characters to keep.</param>
|
||||
/// <returns>The text, clipped to fit when it is longer than the maximum.</returns>
|
||||
public static string Clip(string? text, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return string.Empty;
|
||||
@@ -329,6 +347,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the
|
||||
/// shared head is shown once in the lane gutter and each node shows the tail that tells it apart.
|
||||
/// </summary>
|
||||
/// <param name="titles">The node titles on the lane.</param>
|
||||
/// <returns>The shared dotted prefix, or null when there is none worth showing.</returns>
|
||||
public static string? CommonDottedPrefix(IReadOnlyList<string> titles)
|
||||
{
|
||||
if (titles.Count < 2) return null;
|
||||
@@ -352,6 +372,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than
|
||||
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
|
||||
/// </summary>
|
||||
/// <param name="text">The title text; may be null.</param>
|
||||
/// <returns>The text with zero-width spaces inserted at preferred break points.</returns>
|
||||
public static string WrapHints(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return string.Empty;
|
||||
@@ -373,6 +395,9 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated
|
||||
/// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to strip; may be null.</param>
|
||||
/// <param name="prefix">The prefix to remove; may be null.</param>
|
||||
/// <returns>The text with the prefix removed.</returns>
|
||||
public static string? StripPrefix(string? text, string? prefix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text;
|
||||
@@ -385,6 +410,14 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// <c><text></c> tag for its own markup-mode switch and refuses attributes on it,
|
||||
/// so the SVG element of the same name cannot be written literally inside a code block.
|
||||
/// </summary>
|
||||
/// <param name="x">The x coordinate.</param>
|
||||
/// <param name="y">The y coordinate.</param>
|
||||
/// <param name="cssClass">The CSS class for the element.</param>
|
||||
/// <param name="content">The text content, encoded by this method.</param>
|
||||
/// <param name="anchor">The optional <c>text-anchor</c> value.</param>
|
||||
/// <param name="title">The optional tooltip title.</param>
|
||||
/// <param name="dataTest">The optional <c>data-test</c> attribute value.</param>
|
||||
/// <returns>The encoded SVG text element markup.</returns>
|
||||
public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null)
|
||||
{
|
||||
var sb = new StringBuilder("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"');
|
||||
@@ -427,6 +460,10 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
|
||||
/// Kept in step with those two path builders.
|
||||
/// </summary>
|
||||
/// <param name="a">The source node.</param>
|
||||
/// <param name="b">The target node.</param>
|
||||
/// <param name="isAsync">True when the connector is an async branch.</param>
|
||||
/// <returns>The midpoint of the connector's vertical run.</returns>
|
||||
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
|
||||
{
|
||||
var x1 = a.X + NodeWidth;
|
||||
@@ -460,6 +497,7 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// </summary>
|
||||
/// <param name="graph">The real graph, or null.</param>
|
||||
/// <param name="predicted">The prediction, or null.</param>
|
||||
/// <returns>The computed layout.</returns>
|
||||
public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted)
|
||||
{
|
||||
var hasReal = graph is { Steps.Count: > 0 };
|
||||
@@ -652,6 +690,8 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c>
|
||||
/// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused.
|
||||
/// </summary>
|
||||
/// <param name="distinctTargets">The distinct external targets in first-seen order.</param>
|
||||
/// <returns>The gutter sublabel text.</returns>
|
||||
public static string ExternalSublabel(IReadOnlyList<string> distinctTargets)
|
||||
{
|
||||
if (distinctTargets.Count == 0) return string.Empty;
|
||||
@@ -666,6 +706,9 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// crosses a node sitting between the two lanes. Falls back to <see cref="OrthogonalPath"/> when
|
||||
/// the target is not below the source.
|
||||
/// </summary>
|
||||
/// <param name="a">The source node.</param>
|
||||
/// <param name="b">The target node.</param>
|
||||
/// <returns>The SVG path data for the connector.</returns>
|
||||
public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b)
|
||||
{
|
||||
if (b.Y <= a.Y)
|
||||
@@ -682,6 +725,9 @@ public partial class FlowGraphView : ComponentBase
|
||||
/// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right
|
||||
/// through the inter-column gap. A backwards edge (target left of source) loops under.
|
||||
/// </summary>
|
||||
/// <param name="a">The source node.</param>
|
||||
/// <param name="b">The target node.</param>
|
||||
/// <returns>The SVG path data for the connector.</returns>
|
||||
public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b)
|
||||
{
|
||||
var x1 = a.X + NodeWidth;
|
||||
|
||||
@@ -58,8 +58,8 @@ public interface IDialogService
|
||||
/// button's styling.</param>
|
||||
/// <param name="confirmLabel">Verb rendered on the confirm button. Only
|
||||
/// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is
|
||||
/// blank; the other severities fall back to "Confirm". The row-28 rule still
|
||||
/// applies — name the action.</param>
|
||||
/// blank; the other severities fall back to "Confirm". The rule to name the
|
||||
/// action still applies.</param>
|
||||
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null);
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
|
||||
private readonly StringBuilder _retained = new(Math.Max(0, capacity));
|
||||
private readonly object _gate = new();
|
||||
private long _dropped;
|
||||
/// <summary>Number of characters currently retained.</summary>
|
||||
public int RetainedCharacters { get { lock (_gate) return _retained.Length; } }
|
||||
/// <summary>Number of characters discarded because the budget was reached.</summary>
|
||||
public long DroppedCharacters { get { lock (_gate) return _dropped; } }
|
||||
/// <inheritdoc />
|
||||
public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); }
|
||||
/// <inheritdoc />
|
||||
public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); }
|
||||
/// <inheritdoc />
|
||||
public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count));
|
||||
/// <inheritdoc />
|
||||
public override void Write(ReadOnlySpan<char> buffer)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -22,24 +28,32 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
|
||||
_dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take;
|
||||
}
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } }
|
||||
/// <inheritdoc />
|
||||
public override void WriteLine() => Write(NewLine);
|
||||
/// <inheritdoc />
|
||||
public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); }
|
||||
}
|
||||
|
||||
/// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary>
|
||||
public sealed class BoundedSandboxStream(int capacity) : MemoryStream
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
|
||||
/// <inheritdoc />
|
||||
public override void Write(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget.");
|
||||
var bounded = buffer.ToArray();
|
||||
base.Write(bounded, 0, bounded.Length);
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); }
|
||||
/// <inheritdoc />
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; }
|
||||
/// <inheritdoc />
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
@@ -521,7 +521,7 @@ public class SandboxAttributeAccessor
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
|
||||
/// overload (Gitea #36): present for editor/compile parity, throws
|
||||
/// overload: present for editor/compile parity, throws
|
||||
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
|
||||
/// </summary>
|
||||
/// <param name="values">The attribute values to write as a batch.</param>
|
||||
|
||||
@@ -21,10 +21,22 @@ public static class SandboxWorkerProtocol
|
||||
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
|
||||
|
||||
/// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary>
|
||||
/// <param name="request">The sandbox run request to execute in the child process.</param>
|
||||
/// <param name="services">The service provider supplying host services for the run.</param>
|
||||
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
|
||||
/// <param name="caller">Cancellation token from the caller.</param>
|
||||
/// <returns>The sandbox run result.</returns>
|
||||
public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services,
|
||||
ISharedScriptCatalog catalog, CancellationToken caller)
|
||||
=> await RunCoreAsync(request, services, catalog, caller);
|
||||
|
||||
/// <summary>Core run implementation; exposes a callback that reports the child process id once started.</summary>
|
||||
/// <param name="request">The sandbox run request to execute in the child process.</param>
|
||||
/// <param name="services">The service provider supplying host services for the run.</param>
|
||||
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
|
||||
/// <param name="caller">Cancellation token from the caller.</param>
|
||||
/// <param name="started">Optional callback invoked with the child process id after launch.</param>
|
||||
/// <returns>The sandbox run result.</returns>
|
||||
internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
|
||||
ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null)
|
||||
{
|
||||
@@ -163,6 +175,7 @@ public static class SandboxWorkerProtocol
|
||||
}
|
||||
|
||||
/// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public static async Task RunWorkerAsync()
|
||||
{
|
||||
var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput();
|
||||
@@ -195,15 +208,21 @@ public static class SandboxWorkerProtocol
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]);
|
||||
/// <inheritdoc />
|
||||
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json);
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
|
||||
=> (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
|
||||
=> (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
|
||||
/// <inheritdoc />
|
||||
public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!;
|
||||
@@ -213,11 +232,15 @@ public static class SandboxWorkerProtocol
|
||||
if (info.Open) await connection.OpenAsync(cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
|
||||
=> ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct));
|
||||
/// <inheritdoc />
|
||||
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct);
|
||||
/// <inheritdoc />
|
||||
public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
|
||||
=> ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct));
|
||||
/// <inheritdoc />
|
||||
public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
|
||||
=> (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ public class ScriptAnalysisService
|
||||
|
||||
/// <summary>
|
||||
/// Shared options for the per-Test-Run return-value JSON serialize in
|
||||
/// <see cref="SerializeReturn"/> (perf remediation, arch-review WP1.5) — avoids
|
||||
/// <see cref="SerializeReturn"/> — avoids
|
||||
/// allocating a new <see cref="JsonSerializerOptions"/> per run. Settings preserved
|
||||
/// exactly.
|
||||
/// </summary>
|
||||
@@ -237,6 +237,9 @@ public class ScriptAnalysisService
|
||||
=> SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct);
|
||||
|
||||
/// <summary>Execution core invoked only by the disposable worker executable.</summary>
|
||||
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
|
||||
/// <param name="ct">Cancellation token for the run.</param>
|
||||
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
|
||||
public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
@@ -537,6 +540,9 @@ public class ScriptAnalysisService
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Converts a <see cref="JsonElement"/> into the equivalent CLR object (string, number, bool, list, dictionary or null).</summary>
|
||||
/// <param name="element">The JSON element to convert.</param>
|
||||
/// <returns>The converted CLR value.</returns>
|
||||
internal static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
|
||||
@@ -111,12 +111,11 @@ public static class ServiceCollectionExtensions
|
||||
// The fan-out itself stays scoped (it holds scoped repositories + the snapshot
|
||||
// client), but the page resolves it through SharedAlarmSummaryService — a process
|
||||
// singleton that memoizes the fan-out per site with single-flight, so N circuits
|
||||
// watching one site cost ONE fan-out per window instead of N (arch-review WP2.4).
|
||||
// watching one site cost ONE fan-out per window instead of N.
|
||||
services.AddScoped<AlarmSummaryService>();
|
||||
services.AddSingleton<IAlarmSummaryService, SharedAlarmSummaryService>();
|
||||
|
||||
// Process-level memoized point-in-time KPI snapshots (arch-review WP2.4, finding
|
||||
// #8's per-circuit half). Singleton on purpose: the memo has to outlive any one
|
||||
// Process-level memoized point-in-time KPI snapshots. Singleton on purpose: the memo has to outlive any one
|
||||
// circuit for N dashboards to share a single SQL round per refresh window.
|
||||
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
|
||||
/// exports.</c> footer line so an operator can tell a truncated download from
|
||||
/// a complete one. The header row contains the same 24 columns, in the same
|
||||
/// order, as the ManagementService CLI's CSV export (arch-review 2026-08-19
|
||||
/// P6-7 — the two CSV writers were found to have drifted and are kept aligned).
|
||||
/// order, as the ManagementService CLI's CSV export (the two CSV writers
|
||||
/// had drifted and are kept aligned).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IAuditLogExportService
|
||||
@@ -162,7 +162,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// CSV helpers — 24 columns, kept aligned with the ManagementService CLI's
|
||||
// CSV export (arch-review 2026-08-19 P6-7)
|
||||
// CSV export
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The 24 column names, in <see cref="AuditEventView"/> declaration order.</summary>
|
||||
@@ -179,8 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
/// cleanly on another. Free-text columns are run through
|
||||
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
|
||||
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
|
||||
/// a formula or DDE trigger by a spreadsheet application (CWE-1236;
|
||||
/// arch-review 2026-08-19 P6-7). GUID/enum/numeric/date/bool columns this
|
||||
/// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this
|
||||
/// formatter generates itself are never neutralized.
|
||||
/// </summary>
|
||||
/// <param name="evt">The audit event view to format as a CSV row.</param>
|
||||
|
||||
@@ -70,6 +70,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
|
||||
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
|
||||
/// <param name="auth">Optional authentication state provider used to resolve the current user.</param>
|
||||
public AuditLogQueryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
|
||||
|
||||
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
public sealed class AuthorizedManualFailoverService(AuthenticationStateProvider auth, IManualFailoverService backend)
|
||||
{
|
||||
/// <summary>Fails over central only for a currently authorized administrator.</summary>
|
||||
/// <returns>The failover outcome message, or <c>null</c> when none was reported.</returns>
|
||||
public async Task<string?> FailOverCentralAsync()
|
||||
=> await backend.FailOverCentralAsync(await auth.RequireRoleAsync(Roles.Administrator));
|
||||
|
||||
/// <summary>Fails over a site only for a currently authorized administrator.</summary>
|
||||
/// <param name="siteId">The site identifier to fail over.</param>
|
||||
/// <returns>The outcome of the site failover.</returns>
|
||||
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId)
|
||||
=> await backend.FailOverSiteAsync(siteId, await auth.RequireRoleAsync(Roles.Administrator));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum BindingConnectionState { None, Single, Mixed }
|
||||
/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
|
||||
public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
|
||||
{
|
||||
/// <summary>True when the row has no bound (or a non-positive) connection id.</summary>
|
||||
public bool IsUnbound => Row.ConnectionId is null or <= 0;
|
||||
/// <summary>Effective reference: override if set, else the default.</summary>
|
||||
public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
|
||||
@@ -32,6 +33,7 @@ public sealed record BindingGroup(
|
||||
int? CommonConnectionId,
|
||||
int UnboundCount)
|
||||
{
|
||||
/// <summary>True when this group holds the instance's direct (non-module) attributes.</summary>
|
||||
public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
|
||||
}
|
||||
|
||||
@@ -44,12 +46,14 @@ public static class BindingGroupBuilder
|
||||
{
|
||||
public const string InstanceGroupKey = "(instance)";
|
||||
|
||||
/// <summary>Groups the data-sourced rows by module and infers each group's shared connection and object prefix.</summary>
|
||||
/// <param name="rows">The data-sourced flattened attributes with their current binding state.</param>
|
||||
/// <param name="knownConnectionIds">
|
||||
/// The site's data connection ids. When supplied, a row whose connection id is not
|
||||
/// in the set is treated as UNBOUND — a binding to a deleted connection renders as
|
||||
/// "— none —" and the deploy gate rejects it, so the pills must agree.
|
||||
/// </param>
|
||||
/// <returns>One <see cref="BindingGroup"/> per module (or the instance group).</returns>
|
||||
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null)
|
||||
{
|
||||
if (knownConnectionIds is not null)
|
||||
@@ -153,6 +157,10 @@ public static class BindingGroupBuilder
|
||||
/// Save path is an upsert and a removal could not persist anyway. Surrounding
|
||||
/// whitespace and a trailing '.' are tolerated.
|
||||
/// </summary>
|
||||
/// <param name="group">The group whose members are rewritten.</param>
|
||||
/// <param name="connectionId">The connection to bind, or null to keep each member's current connection.</param>
|
||||
/// <param name="objectPath">The object path prefix, or blank to clear the overrides.</param>
|
||||
/// <returns>One change per group member.</returns>
|
||||
public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath)
|
||||
{
|
||||
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
|
||||
|
||||
@@ -12,6 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth)
|
||||
{
|
||||
/// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary>
|
||||
/// <typeparam name="T">The result type of the operation.</typeparam>
|
||||
/// <param name="instanceId">The existing instance id, or null when creating.</param>
|
||||
/// <param name="createSiteId">The target site id when creating an instance.</param>
|
||||
/// <param name="operation">The operation to run, given the instance service and the acting username.</param>
|
||||
/// <returns>The operation's result.</returns>
|
||||
public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId,
|
||||
Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation)
|
||||
{
|
||||
@@ -36,6 +41,8 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
|
||||
}
|
||||
|
||||
/// <summary>Executes one command through the same guarded boundary as the management actor.</summary>
|
||||
/// <param name="command">The configuration command to execute.</param>
|
||||
/// <returns>The command's result, if any.</returns>
|
||||
public async Task<object?> ExecuteAsync(object command)
|
||||
{
|
||||
var principal = (await auth.GetAuthoritativeStateAsync()).User;
|
||||
@@ -52,6 +59,9 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
|
||||
public static class ConfigurationCommandExtensions
|
||||
{
|
||||
/// <summary>Submits a database-only configuration mutation with current server-side authority.</summary>
|
||||
/// <param name="services">The service provider used to resolve the command service.</param>
|
||||
/// <param name="command">The configuration command to execute.</param>
|
||||
/// <returns>The command's result, if any.</returns>
|
||||
public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command)
|
||||
=> services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
/// NAME keys back to <c>DataConnection.Id</c> using the same site repository the
|
||||
/// design page loads from. Health reports key connections by <c>Name</c>, the
|
||||
/// page renders nodes by id, so the join is per-site Name → Id; doing it per-site
|
||||
/// avoids cross-site name collisions (two sites can each own a "PLC-1").
|
||||
/// avoids cross-site name collisions (two sites can each own a "MainPlc").
|
||||
/// </remarks>
|
||||
public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService
|
||||
{
|
||||
|
||||
@@ -59,6 +59,8 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
/// the run itself at <c>timeoutSeconds + RunGrace</c> (see <c>FlowDebugRunner.RunMethodAsync</c>),
|
||||
/// so the client must wait at least that long or it cuts the run off before the server does.
|
||||
/// </summary>
|
||||
/// <param name="timeoutSeconds">The method's own timeout in seconds.</param>
|
||||
/// <returns>The Ask timeout to use for the run.</returns>
|
||||
public static TimeSpan RunAskTimeoutFor(int timeoutSeconds)
|
||||
{
|
||||
var t = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)) + FlowDebugRunner.RunGrace + ClientSlack;
|
||||
@@ -69,6 +71,8 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
|
||||
/// trigger-wait window plus <see cref="ClientSlack"/>.
|
||||
/// </summary>
|
||||
/// <param name="waitSeconds">The trigger-wait window in seconds.</param>
|
||||
/// <returns>The Ask timeout to use for the run.</returns>
|
||||
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
|
||||
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
|
||||
|
||||
@@ -110,6 +114,14 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
IReadOnlyList<FlowScriptOption> Options);
|
||||
|
||||
/// <summary>Initializes the service.</summary>
|
||||
/// <param name="holder">Holder for the management actor reference.</param>
|
||||
/// <param name="auth">Provides the caller's authentication state.</param>
|
||||
/// <param name="inboundApi">Repository for inbound API methods.</param>
|
||||
/// <param name="templates">Repository for templates and instances.</param>
|
||||
/// <param name="pipeline">Flattening pipeline used to resolve instance scripts.</param>
|
||||
/// <param name="comms">Communication service for central-to-site requests.</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
/// <param name="sites">Repository for sites.</param>
|
||||
public FlowDebugService(
|
||||
ManagementActorHolder holder,
|
||||
AuthenticationStateProvider auth,
|
||||
|
||||
@@ -44,7 +44,7 @@ public interface IAlarmSummaryService
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="AlarmSummaryResult"/> directly from an in-memory live
|
||||
/// snapshot (plan #10, Task 5) — the current alarms served by
|
||||
/// snapshot — the current alarms served by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.ISiteAlarmLiveCache.GetCurrentAlarms"/>
|
||||
/// — instead of fanning out per-instance snapshot Asks. Each
|
||||
/// <see cref="AlarmStateChanged"/> is flattened to one
|
||||
|
||||
@@ -28,6 +28,9 @@ public sealed record FlowDebugOutcome(
|
||||
public bool Success => Graph != null && Error == null;
|
||||
|
||||
/// <summary>Creates a failed outcome.</summary>
|
||||
/// <param name="error">The error message.</param>
|
||||
/// <param name="errorCode">The machine-readable error code.</param>
|
||||
/// <returns>A failed outcome carrying the error.</returns>
|
||||
public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode);
|
||||
}
|
||||
|
||||
@@ -83,18 +86,21 @@ public interface IFlowDebugService
|
||||
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
|
||||
/// <param name="instanceId">The instance id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The instance's scripts ordered by canonical name.</returns>
|
||||
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
|
||||
/// <param name="instanceId">The instance id.</param>
|
||||
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The predicted flow for the script.</returns>
|
||||
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary>
|
||||
/// <param name="apiMethodId">The method id.</param>
|
||||
/// <param name="parameters">Flat JSON object of parameters, or null.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The outcome of the run, including the built flow graph.</returns>
|
||||
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
|
||||
@@ -103,11 +109,13 @@ public interface IFlowDebugService
|
||||
/// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param>
|
||||
/// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The outcome of the run, including the built flow graph.</returns>
|
||||
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Rebuilds a past flow from the central audit store.</summary>
|
||||
/// <param name="executionId">Any execution id in the chain.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The outcome of the replay, including the rebuilt flow graph.</returns>
|
||||
Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -120,6 +128,7 @@ public interface IFlowDebugService
|
||||
/// </summary>
|
||||
/// <param name="step">The outbound step to retry.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the retry request.</returns>
|
||||
Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -128,5 +137,6 @@ public interface IFlowDebugService
|
||||
/// </summary>
|
||||
/// <param name="step">The outbound step to discard.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the discard request.</returns>
|
||||
Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level memoized point-in-time KPI snapshots shared by every Blazor circuit on
|
||||
/// this node (arch-review WP2.4, finding #8 — the per-circuit polling half).
|
||||
/// this node (the per-circuit polling half).
|
||||
/// <para>
|
||||
/// Every KPI here is a <em>global, point-in-time aggregate</em>: it does not vary by user,
|
||||
/// site scope, or role, so N circuits asking for it inside one refresh window are asking
|
||||
|
||||
@@ -13,6 +13,9 @@ public sealed class PollGate
|
||||
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
|
||||
|
||||
/// <summary>Runs and observes a posted refresh, releasing the gate even if dispatch is rejected.</summary>
|
||||
/// <param name="dispatch">Delegate that posts the refresh.</param>
|
||||
/// <param name="onError">Callback invoked with any exception thrown by <paramref name="dispatch"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task RunAsync(Func<Task> dispatch, Action<Exception> onError)
|
||||
{
|
||||
if (!TryEnter()) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/> (arch-review WP2.4).
|
||||
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/>.
|
||||
/// <para>
|
||||
/// The Alarm Summary page polls every 15s <em>per circuit</em>, and each poll fans one debug
|
||||
/// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site
|
||||
|
||||
@@ -17,6 +17,9 @@ public static class TemplatePathFormatter
|
||||
/// Builds the display path for <paramref name="template"/> by walking its
|
||||
/// folder chain upward through <paramref name="foldersById"/>.
|
||||
/// </summary>
|
||||
/// <param name="template">The template whose path is built.</param>
|
||||
/// <param name="foldersById">Folders keyed by id, used to walk the parent chain.</param>
|
||||
/// <returns>The folder-qualified display path.</returns>
|
||||
public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById)
|
||||
{
|
||||
var parts = new List<string> { template.Name };
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for the simultaneous-cold-start split-brain guard (Gitea #33).
|
||||
/// Pure decision logic for the simultaneous-cold-start split-brain guard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -119,7 +119,7 @@ public class ClusterOptions
|
||||
/// The simultaneous-cold-start split-brain guard (<c>ScadaBridge:Cluster:BootstrapGuard</c>).
|
||||
/// Default OFF — a dark switch, so existing deployments and tests keep Akka's config-driven
|
||||
/// self-first auto-join byte-identical. See <see cref="ClusterBootstrapGuard"/> for the
|
||||
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime (Gitea #33).
|
||||
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime.
|
||||
/// </summary>
|
||||
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the bootstrap guard is enabled (Gitea #33), its timing knobs must be positive. A
|
||||
/// When the bootstrap guard is enabled, its timing knobs must be positive. A
|
||||
/// zero/negative <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/> in particular
|
||||
/// silently degrades the guard to "never wait, always conclude the partner is dead" — the
|
||||
/// higher node would form alone immediately and re-open the very split the guard exists to
|
||||
|
||||
@@ -292,6 +292,7 @@ public interface IAuditLogRepository
|
||||
/// </remarks>
|
||||
/// <param name="executionId">Any execution id in the chain; the implementation walks to the root and back down.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="permittedSiteIds">Optional site scope restricting which sites' rows are visible; <c>null</c> means unrestricted.</param>
|
||||
/// <returns>A task that resolves to the full execution tree rooted at the topmost ancestor, one node per distinct execution.</returns>
|
||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
@@ -307,6 +308,7 @@ public interface IAuditLogRepository
|
||||
/// </summary>
|
||||
/// <param name="executionId">Any execution id in the chain.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="permittedSiteIds">Optional site scope restricting which sites' rows are visible; <c>null</c> means unrestricted.</param>
|
||||
/// <returns>The canonical audit events that make up the flow, oldest first.</returns>
|
||||
Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(
|
||||
Guid executionId,
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ public interface INotificationOutboxRepository
|
||||
/// is its own transaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why this exists (arch-review 2026-08-19 P5-4).</b> The operator retry/
|
||||
/// <b>Why this exists.</b> The operator retry/
|
||||
/// discard handlers are read-check-write across two round trips:
|
||||
/// <see cref="GetByIdAsync"/> reads the row, the handler checks its
|
||||
/// in-memory <c>Status</c>, then a write persists the transition. A detached
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ public interface ISecuredWriteRepository
|
||||
/// the compare-and-swap guard for the approve-vs-reject race: a stale-read reject
|
||||
/// used to land a blind full-row update while a concurrent approve's multi-second
|
||||
/// site relay was still in flight, marking the row <c>Rejected</c> while the plant
|
||||
/// write physically executed (arch-review 2026-08-19 P6-1). The conditional
|
||||
/// write physically executed. The conditional
|
||||
/// <c>WHERE Status='Pending'</c> makes exactly one of approve/reject/expiry win.
|
||||
/// The loser observes <c>false</c> and must record nothing — no status change and no
|
||||
/// reject audit row.
|
||||
|
||||
@@ -252,6 +252,12 @@ public interface ITemplateEngineRepository
|
||||
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reads one bounded, untracked page of topology instance rows for a site/area.</summary>
|
||||
/// <param name="siteId">The site ID.</param>
|
||||
/// <param name="areaId">The area ID to filter by, or null for every area.</param>
|
||||
/// <param name="skip">The number of rows to skip.</param>
|
||||
/// <param name="take">The maximum number of rows to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a read-only page of instances.</returns>
|
||||
Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Retrieves an instance by unique name.</summary>
|
||||
|
||||
+3
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
public interface IConfigurationCommandTransaction
|
||||
{
|
||||
/// <summary>Each database retry supplies a fresh scope; the operation must not perform external side effects.</summary>
|
||||
/// <param name="operation">The database-only operation to run within the transaction.</param>
|
||||
/// <returns>The operation's result, if any.</returns>
|
||||
Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation);
|
||||
}
|
||||
|
||||
@@ -11,6 +13,7 @@ public interface IConfigurationCommandTransaction
|
||||
public interface IConfigurationCommitActions
|
||||
{
|
||||
/// <summary>Queues an idempotent local maintenance action.</summary>
|
||||
/// <param name="action">The action to run after a successful commit.</param>
|
||||
void Add(Func<Task> action);
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -2,5 +2,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
/// <summary>Private worker handoff for an existing configured SQL connection. Never log or expose the returned secret.</summary>
|
||||
public interface ISandboxDatabaseConnectionSource
|
||||
{
|
||||
/// <summary>Resolves the connection string for a configured connection.</summary>
|
||||
/// <param name="connectionName">The configured database connection name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The connection string; a secret that must never be logged or exposed.</returns>
|
||||
Task<string> GetSandboxConnectionStringAsync(string connectionName, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ public interface ISiteAuditQueue
|
||||
/// <param name="sinceUtc">Lower bound timestamp (UTC).</param>
|
||||
/// <param name="batchSize">Maximum number of rows to return.</param>
|
||||
/// <param name="afterId">
|
||||
/// Composite-keyset tiebreak cursor (WP2.3), mirroring
|
||||
/// Composite-keyset tiebreak cursor, mirroring
|
||||
/// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/>: when non-null it is the
|
||||
/// <see cref="AuditEvent.EventId"/> ("D" GUID form) of the last row already consumed at
|
||||
/// <paramref name="sinceUtc"/>, and only rows strictly after the composite
|
||||
@@ -125,7 +125,7 @@ public interface ISiteAuditQueue
|
||||
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation-pull commit surface, cursor form (WP2.3): flips every row at or before
|
||||
/// Reconciliation-pull commit surface, cursor form: flips every row at or before
|
||||
/// the composite cursor <c>(<paramref name="sinceUtc"/>, <paramref name="afterId"/>)</c>
|
||||
/// from <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>/
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/> to
|
||||
@@ -231,6 +231,12 @@ public interface ISiteAuditQueue
|
||||
/// <c>(OccurredAtUtc, EventId)</c>, at most <paramref name="limit"/> rows. Independent of
|
||||
/// forwarding state — moves no cursor and flips nothing. At most 64 ids per call.
|
||||
/// </summary>
|
||||
/// <param name="executionIds">Execution ids to match against <c>ExecutionId</c> or <c>ParentExecutionId</c>.</param>
|
||||
/// <param name="sinceUtc">Lower bound on <c>OccurredAtUtc</c> (UTC).</param>
|
||||
/// <param name="limit">Maximum number of rows to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="afterEventId">Optional <c>EventId</c> keyset cursor for paging past rows already consumed.</param>
|
||||
/// <returns>A task that resolves to the matching audit events ordered by <c>(OccurredAtUtc, EventId)</c>.</returns>
|
||||
Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
/// <param name="CorrelationId">Correlation id echoed on the reply.</param>
|
||||
/// <param name="AlarmsOnly">
|
||||
/// When <c>true</c> the site builds ONLY the alarm half of the snapshot and returns an
|
||||
/// empty <see cref="DebugViewSnapshot.AttributeValues"/> list (wire efficiency, WP2.3).
|
||||
/// empty <see cref="DebugViewSnapshot.AttributeValues"/> list (wire efficiency).
|
||||
/// Set by the central per-site live alarm cache, whose seed/reconcile fan-out discards
|
||||
/// every attribute row anyway. Defaults to <c>false</c> so the Debug View and every other
|
||||
/// caller keep the full snapshot; additive on the wire
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
|
||||
/// <summary>
|
||||
/// One site node's latest deployed-secrets mirror outcome, as carried on
|
||||
/// <see cref="SiteHealthReport.SecretMirrorStatuses"/> (review finding M1).
|
||||
/// <see cref="SiteHealthReport.SecretMirrorStatuses"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every site node runs its own <c>DeployedSecretsMirror</c> and records its current
|
||||
|
||||
@@ -75,7 +75,7 @@ public record SiteHealthReport(
|
||||
public double? ScriptOldestBusyAgeSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1: script-execution worker threads the stuck-script watchdog has DETACHED and
|
||||
/// Script-execution worker threads the stuck-script watchdog has DETACHED and
|
||||
/// replaced — a script wedged past its deadline plus grace in uninterruptible blocking
|
||||
/// I/O, so the pool started a fresh thread and the wedged one will exit only when its
|
||||
/// body finally returns. Point-in-time, refreshed by <c>ScriptSchedulerStatsReporter</c>.
|
||||
@@ -85,7 +85,7 @@ public record SiteHealthReport(
|
||||
public int DetachedScriptThreads { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1: per-interval count of script and alarm on-trigger runs SHED because
|
||||
/// Per-interval count of script and alarm on-trigger runs SHED because
|
||||
/// <c>MaxConcurrentRunsPerScript</c> runs were already in flight for that script. Raw
|
||||
/// per-interval count (drained on collect) like <see cref="ScriptErrorCount"/>. A
|
||||
/// sustained non-zero value means a trigger is firing faster than its script completes;
|
||||
@@ -136,8 +136,7 @@ public record SiteHealthReport(
|
||||
public long? LocalDbOplogBacklog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6d (arch-review misc — site stream alarm-vs-attribute shared buffer):
|
||||
/// cumulative count of alarm state changes dropped from
|
||||
/// Cumulative count of alarm state changes dropped from
|
||||
/// <c>SiteStreamManager</c>'s dedicated alarm publish queue because it was at
|
||||
/// capacity — i.e. the site-wide alarm hand-off itself fell behind, not a
|
||||
/// per-subscriber buffer. Point-in-time (not reset on collect), refreshed by
|
||||
@@ -154,9 +153,9 @@ public record SiteHealthReport(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Tri-state, and the distinction is the whole point (deferred-work register row 73).
|
||||
/// Tri-state, and the distinction is the whole point.
|
||||
/// <b>Null = not reported</b>: a central self-report, where the notion does not apply,
|
||||
/// or a site node running a pre-row-73 build. <b><see langword="true"/></b> = the
|
||||
/// or a site node running an older build. <b><see langword="true"/></b> = the
|
||||
/// reconciler is running. <b><see langword="false"/></b> = the node booted WITHOUT it,
|
||||
/// because no <c>IDeploymentConfigFetcher</c> was resolvable.
|
||||
/// </para>
|
||||
@@ -175,12 +174,12 @@ public record SiteHealthReport(
|
||||
public bool? StartupReconcilerActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Every site node's latest deployed-secrets mirror outcome (review finding M1) — one
|
||||
/// Every site node's latest deployed-secrets mirror outcome — one
|
||||
/// entry per node that has recorded a row in the replicated <c>secret_mirror_status</c>
|
||||
/// table, including the STANDBY node, whose own health report is never sent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Null = no data</b>: a central self-report, a pre-M1 site build, a site whose
|
||||
/// <b>Null = no data</b>: a central self-report, an older site build, a site whose
|
||||
/// reporter has not run yet, or a failed read. An EMPTY list means the feature is wired
|
||||
/// but no node has recorded a status yet. A node entry with an empty
|
||||
/// <see cref="SecretMirrorNodeStatus.Failures"/> list is a healthy mirror. Names and
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
/// <param name="Value">Canonical string form of the new value.</param>
|
||||
/// <param name="Timestamp">UTC timestamp of the command.</param>
|
||||
/// <param name="SourceExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
|
||||
/// ParentExecutionId tag-cascade: the <c>ExecutionId</c> of the
|
||||
/// execution issuing this write — a site script run
|
||||
/// (<c>ScriptRuntimeContext.SetAttribute</c>) or the inbound API request behind a
|
||||
/// <c>Route.To(...).SetAttributes(...)</c>. Additive and nullable; <c>null</c>
|
||||
|
||||
@@ -59,7 +59,7 @@ public static class ScadaBridgeTelemetry
|
||||
/// <summary>
|
||||
/// Incremented each time a per-site live-alarm aggregator re-establishes its site-wide
|
||||
/// gRPC stream — a NodeA↔NodeB failover flip or a reconcile-driven reopen after the
|
||||
/// stream was given up (plan #10, Task 6). A sustained climb signals a flapping site link.
|
||||
/// stream was given up. A sustained climb signals a flapping site link.
|
||||
/// </summary>
|
||||
private static readonly Counter<long> _liveAlarmStreamReconnects =
|
||||
Meter.CreateCounter<long>("scadabridge.site.alarm_cache.reconnects", unit: "1",
|
||||
@@ -67,7 +67,7 @@ public static class ScadaBridgeTelemetry
|
||||
|
||||
/// <summary>
|
||||
/// Incremented for each live delta evicted from a per-site live-alarm aggregator's
|
||||
/// bounded pre-seed buffer (drop-oldest, WP2.3). Non-zero means a seed/reconcile
|
||||
/// bounded pre-seed buffer (drop-oldest). Non-zero means a seed/reconcile
|
||||
/// fan-out ran long enough for the delta storm behind it to exceed the cap — the
|
||||
/// dropped transitions are recovered by the fan-out's authoritative snapshot, but a
|
||||
/// sustained climb points at a slow site.
|
||||
@@ -78,7 +78,7 @@ public static class ScadaBridgeTelemetry
|
||||
|
||||
/// <summary>
|
||||
/// Incremented for each debug event evicted from a central debug session's bounded
|
||||
/// pre-snapshot buffer (drop-oldest, WP2.3). The Debug View is lossy-under-backpressure
|
||||
/// pre-snapshot buffer (drop-oldest). The Debug View is lossy-under-backpressure
|
||||
/// by design; this makes the loss measurable instead of unbounded memory growth.
|
||||
/// </summary>
|
||||
private static readonly Counter<long> _debugPreSnapshotDrops =
|
||||
|
||||
@@ -79,7 +79,7 @@ public static class AuditQueryParamParsers
|
||||
/// the filter dimension stays unconstrained.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 99 (closure defect fixed by arch-review 2026-08-19 P6-3). Three
|
||||
/// Register row 99. Three
|
||||
/// traps this closes that a bare
|
||||
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> does not:
|
||||
/// <list type="number">
|
||||
|
||||
@@ -4,8 +4,15 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
/// <summary>Bounded replay data with independently retained chain-root and truncation metadata.</summary>
|
||||
public sealed record TraceEventBatch(Guid RootExecutionId, IReadOnlyList<AuditEvent> Events, bool Truncated) : IReadOnlyList<AuditEvent>
|
||||
{
|
||||
/// <summary>Gets the trace event at the given position.</summary>
|
||||
/// <param name="index">Zero-based position in <see cref="Events"/>.</param>
|
||||
/// <returns>The event at <paramref name="index"/>.</returns>
|
||||
public AuditEvent this[int index] => Events[index];
|
||||
/// <summary>Gets the number of events in the batch.</summary>
|
||||
public int Count => Events.Count;
|
||||
/// <summary>Returns an enumerator over the batch's events.</summary>
|
||||
/// <returns>An enumerator over <see cref="Events"/>.</returns>
|
||||
public IEnumerator<AuditEvent> GetEnumerator() => Events.GetEnumerator();
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ public static class OpcUaReferenceForm
|
||||
/// prefix, or empty/whitespace (nothing to warn about). False only for a bare
|
||||
/// server-namespace index (<c>ns=<n>;…</c> with n ≥ 1).
|
||||
/// </summary>
|
||||
/// <param name="reference">The OPC UA node reference string to classify.</param>
|
||||
/// <returns><see langword="true"/> when the reference is index-durable; otherwise <see langword="false"/>.</returns>
|
||||
public static bool IsDurable(string? reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger (final review M1): the ONE parser for the header line of a captured
|
||||
/// Flow Debugger: the ONE parser for the header line of a captured
|
||||
/// exception detail. A .NET <c>Exception.ToString()</c> opens with
|
||||
/// <c>Namespace.Type: message</c>, and a wrapper (an <c>AggregateException</c>, a rethrow)
|
||||
/// chains its inner exceptions on that same first line with <c>" ---> "</c>.
|
||||
|
||||
@@ -59,6 +59,10 @@ public static class FlowGraphBuilder
|
||||
/// Builds the graph. <paramref name="truncated"/> and <paramref name="logsTruncated"/> are passed
|
||||
/// through to <see cref="FlowGraph.Truncated"/> / <see cref="FlowGraph.LogsTruncated"/>.
|
||||
/// </summary>
|
||||
/// <param name="events">The audit events to project into the graph.</param>
|
||||
/// <param name="truncated">Whether the event set was truncated.</param>
|
||||
/// <param name="logsTruncated">Whether the log rows were truncated.</param>
|
||||
/// <returns>The built flow graph.</returns>
|
||||
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false, bool logsTruncated = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
@@ -338,8 +342,10 @@ public static class FlowGraphBuilder
|
||||
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
|
||||
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
|
||||
/// projector uses for the <c>sync</c> / <c>cached</c> subtitle. Such steps hang off the chain on an
|
||||
/// <see cref="FlowEdge.Async"/> edge (D4).
|
||||
/// <see cref="FlowEdge.Async"/> edge.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to classify.</param>
|
||||
/// <returns>True when the step is not awaited by the script.</returns>
|
||||
public static bool IsAsync(FlowStep step)
|
||||
{
|
||||
if (step.Kind == FlowStepKind.Notify) return true;
|
||||
@@ -352,9 +358,12 @@ public static class FlowGraphBuilder
|
||||
|
||||
/// <summary>
|
||||
/// Orders the rows of one outbound operation when timestamps tie: submit before attempts before the
|
||||
/// resolve, then non-terminal before terminal statuses — so "first" and "latest" (D3) never fall back
|
||||
/// resolve, then non-terminal before terminal statuses — so "first" and "latest" never fall back
|
||||
/// to GUID order for rows written in the same tick.
|
||||
/// </summary>
|
||||
/// <param name="kind">The audit row kind.</param>
|
||||
/// <param name="status">The audit row status.</param>
|
||||
/// <returns>The rank used to order the rows.</returns>
|
||||
public static int LifecycleRank(string? kind, string? status)
|
||||
{
|
||||
var k = kind switch
|
||||
@@ -444,6 +453,8 @@ public static class FlowGraphBuilder
|
||||
}
|
||||
|
||||
/// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary>
|
||||
/// <param name="kind">The step kind.</param>
|
||||
/// <returns>The display rank for the kind.</returns>
|
||||
public static int KindRank(FlowStepKind kind) => kind switch
|
||||
{
|
||||
FlowStepKind.InboundRequest => 0,
|
||||
|
||||
@@ -14,12 +14,18 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
public static class FlowStepProjector
|
||||
{
|
||||
/// <summary>Projects an audit event onto a flow step.</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <returns>The projected flow step.</returns>
|
||||
public static FlowStep Project(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
return Project(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
|
||||
}
|
||||
|
||||
/// <summary>Projects an audit event and its already-deserialized details onto a flow step.</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
|
||||
/// <returns>The projected flow step.</returns>
|
||||
internal static FlowStep Project(AuditEvent evt, AuditDetails d)
|
||||
{
|
||||
var extra = ScriptTraceExtra.TryParse(d.Extra);
|
||||
@@ -52,6 +58,8 @@ public static class FlowStepProjector
|
||||
}
|
||||
|
||||
/// <summary>True for a <c>ScriptTrace.ScriptLog</c> row — a log line, never a step.</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <returns><see langword="true"/> when the event is a script log line.</returns>
|
||||
public static bool IsLogLine(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
@@ -59,6 +67,9 @@ public static class FlowStepProjector
|
||||
return IsLogLine(d);
|
||||
}
|
||||
|
||||
/// <summary>True when the details describe a <c>ScriptTrace.ScriptLog</c> row.</summary>
|
||||
/// <param name="d">The deserialized audit details of the event.</param>
|
||||
/// <returns><see langword="true"/> when the details describe a script log line.</returns>
|
||||
internal static bool IsLogLine(AuditDetails d)
|
||||
{
|
||||
return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)
|
||||
@@ -66,12 +77,18 @@ public static class FlowStepProjector
|
||||
}
|
||||
|
||||
/// <summary>Projects a <c>ScriptLog</c> row onto its line. Level/source default to <c>info</c>/<c>script</c> when the extra is missing.</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <returns>The projected log line.</returns>
|
||||
public static FlowLogLine ToLogLine(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
return ToLogLine(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
|
||||
}
|
||||
|
||||
/// <summary>Projects a <c>ScriptLog</c> row and its already-deserialized details onto its line.</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
|
||||
/// <returns>The projected log line.</returns>
|
||||
internal static FlowLogLine ToLogLine(AuditEvent evt, AuditDetails d)
|
||||
{
|
||||
var x = ScriptTraceExtra.TryParse(d.Extra);
|
||||
@@ -89,12 +106,18 @@ public static class FlowStepProjector
|
||||
}
|
||||
|
||||
/// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <returns>The projected lifecycle row.</returns>
|
||||
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
return ToLifecycleRow(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
|
||||
}
|
||||
|
||||
/// <summary>Projects one outbound audit row and its already-deserialized details onto its lifecycle entry.</summary>
|
||||
/// <param name="evt">The audit event.</param>
|
||||
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
|
||||
/// <returns>The projected lifecycle row.</returns>
|
||||
internal static FlowLifecycleRow ToLifecycleRow(AuditEvent evt, AuditDetails d)
|
||||
{
|
||||
return new FlowLifecycleRow(
|
||||
@@ -109,6 +132,9 @@ public static class FlowStepProjector
|
||||
}
|
||||
|
||||
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
|
||||
/// <param name="channel">The audit channel name.</param>
|
||||
/// <param name="kind">The audit kind name.</param>
|
||||
/// <returns>The matching step kind, or <see cref="FlowStepKind.Unknown"/>.</returns>
|
||||
public static FlowStepKind ResolveKind(string? channel, string? kind)
|
||||
{
|
||||
if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal))
|
||||
@@ -138,6 +164,9 @@ public static class FlowStepProjector
|
||||
}
|
||||
|
||||
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags; outbound API / DB / notify → External.</summary>
|
||||
/// <param name="kind">The step kind.</param>
|
||||
/// <param name="sourceSiteId">The originating site id, used to place unknown kinds.</param>
|
||||
/// <returns>The lane the step is drawn on.</returns>
|
||||
public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
|
||||
{
|
||||
FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
|
||||
@@ -148,6 +177,8 @@ public static class FlowStepProjector
|
||||
};
|
||||
|
||||
/// <summary>True for the three outbound kinds that live on the External lane.</summary>
|
||||
/// <param name="kind">The step kind.</param>
|
||||
/// <returns><see langword="true"/> for an API call, DB call or notification.</returns>
|
||||
public static bool IsOutbound(FlowStepKind kind)
|
||||
=> kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ public sealed class FlowTraceCollector
|
||||
/// the inline copy is filtered through. A null <paramref name="redactor"/> — a host that
|
||||
/// never called <c>AddAuditLog</c>, or a test — collects raw but is still capped.
|
||||
/// </summary>
|
||||
/// <param name="maxEvents">The maximum number of events kept inline.</param>
|
||||
/// <param name="maxBytes">The maximum summed size of the events' details, in bytes.</param>
|
||||
/// <param name="redactor">The redactor applied to the inline copy; null collects raw.</param>
|
||||
public FlowTraceCollector(
|
||||
int maxEvents = DefaultMaxEvents,
|
||||
int maxBytes = DefaultMaxBytes,
|
||||
@@ -142,6 +145,7 @@ public sealed class FlowTraceCollector
|
||||
/// where it builds <c>ScriptCallResult.TraceEvents</c>. Idempotent: a second call returns
|
||||
/// the same contents.
|
||||
/// </summary>
|
||||
/// <returns>The collected events in emission order.</returns>
|
||||
public IReadOnlyList<AuditEvent> Complete()
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -159,6 +163,7 @@ public sealed class FlowTraceCollector
|
||||
/// test-only peek. A reply path must use <see cref="Complete"/>, otherwise an un-awaited call
|
||||
/// finishing after the reply could mutate the list the reply was built from.
|
||||
/// </summary>
|
||||
/// <returns>A copy of the events collected so far.</returns>
|
||||
public IReadOnlyList<AuditEvent> Snapshot()
|
||||
{
|
||||
lock (_gate) return _events.ToArray();
|
||||
|
||||
@@ -68,9 +68,12 @@ public sealed record ScriptTraceExtra
|
||||
};
|
||||
|
||||
/// <summary>Serializes to the compact camelCase JSON stored in <c>AuditDetails.Extra</c>.</summary>
|
||||
/// <returns>The serialized JSON string.</returns>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
/// <summary>Parses <c>AuditDetails.Extra</c>; returns <c>null</c> for null/blank/invalid JSON — never throws.</summary>
|
||||
/// <param name="json">The raw <c>AuditDetails.Extra</c> JSON text.</param>
|
||||
/// <returns>The parsed value, or <c>null</c> when the input is null, blank or invalid.</returns>
|
||||
public static ScriptTraceExtra? TryParse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a
|
||||
/// <see cref="HandshakeResult"/> describes (Gitea #36, design 2026-09-10 §4).
|
||||
/// <see cref="HandshakeResult"/> describes.
|
||||
/// </summary>
|
||||
public enum HandshakePhase
|
||||
{
|
||||
@@ -14,7 +14,7 @@ public enum HandshakePhase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload (Gitea #36):
|
||||
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload:
|
||||
/// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> the outcome belongs to so a
|
||||
/// script can report "timed out waiting for the reset echo" separately from "timed out
|
||||
/// waiting for the device to complete". A reset that is never echoed means the batch and
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
/// How a KPI metric must be folded when an hour of one-minute
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample"/> rows is
|
||||
/// aggregated into a single <c>KpiRollupHourly</c> row ("KPI History & Trends"
|
||||
/// hourly rollups, deferred #22). The correct fold differs by metric semantics, and
|
||||
/// hourly rollups). The correct fold differs by metric semantics, and
|
||||
/// choosing the wrong one silently corrupts long-range trend charts.
|
||||
/// </summary>
|
||||
public enum KpiRollupAggregation
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
public static class ScriptTriggerAttribute
|
||||
{
|
||||
/// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary>
|
||||
/// <param name="triggerType">The trigger type name.</param>
|
||||
/// <returns>True when the trigger type is a write kind.</returns>
|
||||
public static bool IsWriteKind(string? triggerType) =>
|
||||
string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -20,6 +22,10 @@ public static class ScriptTriggerAttribute
|
||||
/// false (and null) for every other kind, and for a write kind whose config is missing,
|
||||
/// malformed, not an object, or names no attribute.
|
||||
/// </summary>
|
||||
/// <param name="triggerType">The trigger type name.</param>
|
||||
/// <param name="triggerConfigurationJson">The trigger configuration JSON.</param>
|
||||
/// <param name="attribute">Receives the monitored attribute name, or null.</param>
|
||||
/// <returns>True when a monitored attribute was read.</returns>
|
||||
public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute)
|
||||
{
|
||||
attribute = null;
|
||||
|
||||
@@ -20,7 +20,7 @@ public static class SecretReferenceScanner
|
||||
/// <summary>
|
||||
/// The single parser for a <c>secret:<name></c> reference value, shared by this scanner
|
||||
/// (what central ships) and every adapter that resolves the reference at connect time (what
|
||||
/// the site looks up), so both sides always agree on the name (final review M3). The value is
|
||||
/// the site looks up), so both sides always agree on the name. The value is
|
||||
/// trimmed, the <c>secret:</c> prefix matches ordinal-ignore-case, and the remainder is
|
||||
/// trimmed; an empty remainder is not a reference. The config-expander form
|
||||
/// <c>${secret:…}</c> is a different syntax and never matches.
|
||||
@@ -45,6 +45,8 @@ public static class SecretReferenceScanner
|
||||
/// string values and returns the distinct names found, ordinal-sorted, each with the ordinal-sorted,
|
||||
/// de-duplicated list of connection names that referenced it.
|
||||
/// </summary>
|
||||
/// <param name="connections">The data connection artifacts to scan.</param>
|
||||
/// <returns>The distinct referenced secrets, each with the connections that reference it.</returns>
|
||||
public static IReadOnlyList<SecretReference> Scan(IEnumerable<DataConnectionArtifact> connections)
|
||||
{
|
||||
var found = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
@@ -52,7 +52,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
|
||||
internal static TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> (WP2.3). The site builds
|
||||
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> The site builds
|
||||
/// it in milliseconds; if none arrives inside this window the site never answered (the
|
||||
/// Ask was lost, the singleton moved mid-request, the instance actor is wedged) and the
|
||||
/// session must FAIL rather than sit in the buffering phase accumulating live events
|
||||
@@ -125,7 +125,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="AlarmStateChanged"/>) that arrived before the snapshot was delivered.
|
||||
/// Flushed (with per-entity dedup against the snapshot) when the snapshot arrives,
|
||||
/// then never used again. Bounded by <see cref="MaxPreSnapshotBuffer"/> with drop-oldest
|
||||
/// eviction (WP2.3): a snapshot that never arrives used to buffer without limit on the
|
||||
/// eviction: a snapshot that never arrives used to buffer without limit on the
|
||||
/// central node. Mutated only on the actor thread.
|
||||
/// </summary>
|
||||
private readonly Queue<object> _preSnapshotBuffer = new();
|
||||
@@ -243,7 +243,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
|
||||
_snapshotDelivered = true;
|
||||
});
|
||||
|
||||
// Hard snapshot deadline (WP2.3). Nothing else ends a session stuck in the
|
||||
// Hard snapshot deadline. Nothing else ends a session stuck in the
|
||||
// buffering phase: the site's reply was lost, so no gRPC error fires, the stream
|
||||
// keeps delivering events, and stream traffic does not renew the orphan net (which
|
||||
// measures the consumer). Fail the session so the consumer is told and can reopen.
|
||||
@@ -351,7 +351,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
|
||||
_consumerLastSeenUtc = DateTime.UtcNow;
|
||||
});
|
||||
|
||||
// Orphan safety net, CONSUMER-measured (WP2.3 follow-up). A periodic self-tick
|
||||
// Orphan safety net, CONSUMER-measured. A periodic self-tick
|
||||
// compares the consumer-last-seen stamp against ConsumerIdleTimeout; the previous
|
||||
// SetReceiveTimeout(5 min) measured the mailbox instead, and once stream events were
|
||||
// (correctly) marked INotInfluenceReceiveTimeout nothing recurring reset it — the
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// One-per-site aggregator on the active central node backing the operator Alarm
|
||||
/// Summary live cache (plan #10, Task 4). Holds a transient in-memory
|
||||
/// Summary live cache. Holds a transient in-memory
|
||||
/// <c>Dictionary<AlarmKey, AlarmStateChanged></c> of the whole site's current
|
||||
/// alarm state — NO persistence (locked <c>[PERM]</c>: no central alarm store). Created
|
||||
/// and torn down by <see cref="SiteAlarmLiveCacheService"/> under viewer reference-count.
|
||||
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
/// <b>Failover + drift:</b> a gRPC error flips NodeA↔NodeB with the same retry budget +
|
||||
/// stability window as <see cref="DebugStreamBridgeActor"/>. A re-seed runs <b>once per
|
||||
/// successful (re)connect</b> — driven by the stream's connected callback, not by each
|
||||
/// reconnect ATTEMPT (WP2.3: a site outage used to fan a full snapshot out per retry, all
|
||||
/// reconnect ATTEMPT (a site outage used to fan a full snapshot out per retry, all
|
||||
/// of them against the very site that is unreachable). A periodic reconcile snapshot
|
||||
/// (<see cref="_reconcileInterval"/>, default 60s, jittered per site so N aggregators do
|
||||
/// not fan out in lockstep) remains the drift/backstop: it corrects instance-set drift and
|
||||
@@ -68,7 +68,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
private const string OpenDeadlineTimerKey = "alarm-grpc-open-deadline";
|
||||
|
||||
/// <summary>
|
||||
/// Production default for the stream open-attempt deadline (arch-review 2026-08-19 P3-4).
|
||||
/// Production default for the stream open-attempt deadline.
|
||||
/// Comfortably above <c>SiteStreamGrpcClient.ConnectedHeaderTimeout</c> (10 s) so a
|
||||
/// normally-slow connect is never pre-empted, and comfortably below the 60 s reconcile
|
||||
/// interval so the tick that follows always finds the flag released.
|
||||
@@ -125,7 +125,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>
|
||||
/// Ordered buffer of live deltas that arrived while a fan-out was in flight, capped at
|
||||
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (WP2.3 — a stuck fan-out
|
||||
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (a stuck fan-out
|
||||
/// used to buffer without bound). Dropping the OLDEST is the right eviction here: the
|
||||
/// fan-out that follows rebuilds the cache authoritatively, so an evicted delta is
|
||||
/// superseded rather than lost, while the newest transitions — the ones the snapshot may
|
||||
@@ -195,7 +195,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>
|
||||
/// Deadline on ONE stream open attempt, after which <see cref="_openInFlight"/> is
|
||||
/// released so <see cref="OnReconcileTick"/> can reopen (arch-review 2026-08-19 P3-4).
|
||||
/// released so <see cref="OnReconcileTick"/> can reopen.
|
||||
/// <para>
|
||||
/// The three outcomes that clear the flag — connected, error, graceful completion — are
|
||||
/// all events the peer has to produce. A stream that produces NONE of them pins the flag:
|
||||
@@ -244,7 +244,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
private const int BufferWarnThreshold = 10_000;
|
||||
private bool _bufferWarned;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
@@ -263,7 +263,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
/// The second argument is the live-stream liveness flag: <c>false</c> means the site-wide
|
||||
/// gRPC stream is currently down (fault-exhausted or gracefully completed), so the cache
|
||||
/// is only as fresh as the last reconcile and the page must fall back to polling
|
||||
/// (WP2.3 carried residual — <c>IsLive</c> used to stay true across a dead stream).
|
||||
/// (<c>IsLive</c> used to stay true across a dead stream).
|
||||
/// </param>
|
||||
/// <param name="grpcFactory">Factory caching one gRPC client per (site, endpoint).</param>
|
||||
/// <param name="grpcNodeAAddress">gRPC address of the site's node A.</param>
|
||||
@@ -284,7 +284,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
/// up to +20%). Zero disables jitter, which is what tests want for determinism.
|
||||
/// </param>
|
||||
/// <param name="openAttemptTimeout">
|
||||
/// Deadline on one gRPC stream open attempt (P3-4); <c>null</c> takes
|
||||
/// Deadline on one gRPC stream open attempt; <c>null</c> takes
|
||||
/// <see cref="DefaultOpenAttemptTimeout"/> (30 s) and <see cref="TimeSpan.Zero"/> or less
|
||||
/// disables the deadline. See <see cref="_openAttemptTimeout"/>.
|
||||
/// </param>
|
||||
@@ -350,7 +350,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
OnStreamConnected();
|
||||
});
|
||||
|
||||
// The open attempt produced none of its three outcomes inside the deadline (P3-4):
|
||||
// The open attempt produced none of its three outcomes inside the deadline:
|
||||
// release the in-flight flag so the reconcile tick's reopen guard can run again.
|
||||
// Generation-fenced like every other stream callback, and a no-op once the attempt
|
||||
// has already resolved (a late timer racing its own stream's connect).
|
||||
@@ -486,7 +486,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
/// Periodic reconcile backstop: re-run the snapshot fan-out (corrects instance-set drift
|
||||
/// + any missed delta) UNLESS a CONNECT- OR FAILOVER-DRIVEN seed already completed inside
|
||||
/// this window — that seed makes the tick redundant, and running both was the "full
|
||||
/// unconditional snapshot every 60s" waste (WP2.3). A tick's OWN fan-out never arms that
|
||||
/// unconditional snapshot every 60s" waste. A tick's OWN fan-out never arms that
|
||||
/// skip: it used to, which made steady state alternate fan-out/skip and halved the
|
||||
/// effective reconcile rate. If the live stream was previously given up, self-heal it by
|
||||
/// resetting the retry budget and reopening, so a sustained outage never permanently kills
|
||||
@@ -700,7 +700,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
// Backoff retry (WP2.3): the seed leg used to have none — a site returning errors
|
||||
// Backoff retry: the seed leg used to have none — a site returning errors
|
||||
// was re-fanned at full reconcile cadence forever. Retry at reconnectDelay doubling
|
||||
// up to MaxSeedRetryBackoffMultiplier × the reconcile interval; the periodic
|
||||
// reconcile remains the floor once the backoff saturates.
|
||||
@@ -848,7 +848,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
if (_stopped) return;
|
||||
|
||||
// The stream is not live again until the server actually accepts it — liveness flips
|
||||
// on GrpcAlarmStreamConnected, not on the attempt (WP2.3 carried residual).
|
||||
// on GrpcAlarmStreamConnected, not on the attempt.
|
||||
var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
|
||||
_log.Info("Opening site-alarm gRPC stream for {0} to {1}", _siteIdentifier, endpoint);
|
||||
|
||||
@@ -861,7 +861,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
_openInFlight = true;
|
||||
var generation = ++_streamGeneration;
|
||||
|
||||
// Bound this attempt (P3-4). Single-shot on a shared key, so a re-open replaces the
|
||||
// Bound this attempt. Single-shot on a shared key, so a re-open replaces the
|
||||
// previous attempt's deadline rather than stacking one.
|
||||
if (_openAttemptTimeout > TimeSpan.Zero)
|
||||
{
|
||||
@@ -1005,7 +1005,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
|
||||
CleanupGrpc();
|
||||
|
||||
// A completed stream is NOT live: without this the cache kept reporting live for up
|
||||
// to a full reconcile interval after the site closed the feed (WP2.3 residual).
|
||||
// to a full reconcile interval after the site closed the feed.
|
||||
if (wasLive && _seeded)
|
||||
Publish();
|
||||
}
|
||||
@@ -1080,6 +1080,6 @@ internal sealed record ReconnectAlarmStream;
|
||||
internal sealed record GrpcAlarmStreamStable;
|
||||
|
||||
/// <summary>Internal: a stream OPEN attempt produced none of connected/error/completed inside its
|
||||
/// deadline (P3-4), stamped with the generation it was armed for so a late timer from a superseded
|
||||
/// deadline, stamped with the generation it was armed for so a late timer from a superseded
|
||||
/// attempt is ignored. Releases the in-flight guard only — it is not a fault.</summary>
|
||||
internal sealed record GrpcAlarmStreamOpenTimedOut(int Generation);
|
||||
|
||||
@@ -76,6 +76,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// pass a stub so they do not need to load Akka.Cluster into the <c>TestKit</c>
|
||||
/// ActorSystem.
|
||||
/// </param>
|
||||
/// <param name="failOverRole">
|
||||
/// Optional delegate mapping a site identifier to the cluster role used for failover/singleton
|
||||
/// lookups; <c>null</c> uses the default role resolution.
|
||||
/// </param>
|
||||
/// <param name="transport">
|
||||
/// The site→central transport. Production always passes the Host-built gRPC
|
||||
/// <see cref="Grpc.GrpcCentralTransport"/>. <c>null</c> (used by TestKit suites that only
|
||||
|
||||
@@ -149,7 +149,7 @@ public class CommunicationOptions
|
||||
|
||||
/// <summary>
|
||||
/// Send-channel capacity for the site-wide alarm stream (<c>SubscribeSite</c>). Larger
|
||||
/// than the Debug View's (WP2.3): that feed backs the operator Alarm Summary, where a
|
||||
/// than the Debug View's: that feed backs the operator Alarm Summary, where a
|
||||
/// silently dropped transition is a missed alarm rather than a missed diagnostic frame,
|
||||
/// and an alarm burst arriving during a WAN stall must survive the stall.
|
||||
/// </summary>
|
||||
@@ -211,9 +211,9 @@ public class CommunicationOptions
|
||||
/// </summary>
|
||||
public TimeSpan PendingDeploymentPurgeInterval { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
// ── Aggregated live alarm cache (plan #10, Task 4) ───────────────────────────
|
||||
// Introduced by Task 4 with sane defaults; Task 6 formalizes eager validation
|
||||
// (positive interval/linger, positive concurrency/subscriber cap) and telemetry.
|
||||
// ── Aggregated live alarm cache ───────────────────────────────────────────────
|
||||
// Sane defaults, with eager validation (positive interval/linger, positive
|
||||
// concurrency/subscriber cap) and telemetry.
|
||||
|
||||
/// <summary>
|
||||
/// How long the shared per-site live alarm aggregator keeps its gRPC stream +
|
||||
@@ -256,7 +256,7 @@ public class CommunicationOptions
|
||||
|
||||
/// <summary>
|
||||
/// Random jitter added to each per-site reconcile tick, as a fraction of
|
||||
/// <see cref="LiveAlarmCacheReconcileInterval"/> (WP2.3). Without it every aggregator
|
||||
/// <see cref="LiveAlarmCacheReconcileInterval"/>. Without it every aggregator
|
||||
/// started by one central failover fans its whole-site snapshot out on the same 60s
|
||||
/// boundary forever. Zero disables jitter.
|
||||
/// </summary>
|
||||
|
||||
@@ -113,7 +113,7 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
|
||||
+ "entries (a site node must list at least one central gRPC endpoint; central nodes "
|
||||
+ "leave it empty).");
|
||||
|
||||
// ── Aggregated live alarm cache (plan #10, Task 6) ───────────────────────
|
||||
// ── Aggregated live alarm cache ──────────────────────────────────────────
|
||||
// Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator
|
||||
// immediately when the last viewer leaves), only a negative value is invalid.
|
||||
builder.RequireThat(options.LiveAlarmCacheLinger >= TimeSpan.Zero,
|
||||
|
||||
@@ -230,7 +230,7 @@ public class CommunicationService
|
||||
// RouteToSetAttributes / RouteToWaitForAttribute), driven from
|
||||
// InboundAPI/CommunicationServiceInstanceRouter and sharing IntegrationTimeout.
|
||||
// The earlier generic IntegrationCallRequest primitive was never wired to a
|
||||
// producer or a site handler and was removed as dead code (Gitea #32).
|
||||
// producer or a site handler and was removed as dead code.
|
||||
|
||||
// ── Pattern 5: Debug View ──
|
||||
|
||||
@@ -803,7 +803,7 @@ public class CommunicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks a site to gracefully fail over its own two-node pair (Task 10). Central and each
|
||||
/// Asks a site to gracefully fail over its own two-node pair. Central and each
|
||||
/// site are SEPARATE Akka clusters, so central cannot act on site membership directly —
|
||||
/// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against
|
||||
/// its site-specific role and acks the outcome.
|
||||
|
||||
@@ -82,7 +82,7 @@ public class DebugStreamService : IDisposable
|
||||
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A debug stream session with the initial snapshot.</returns>
|
||||
/// <remarks>
|
||||
/// Virtual purely as a test seam: the consumers' dispose-vs-start race (P6-6) can only
|
||||
/// Virtual purely as a test seam: the consumers' dispose-vs-start race can only
|
||||
/// be driven deterministically by a stub that holds the start open. Behaviour and the
|
||||
/// keepalive/orphan contract are unchanged.
|
||||
/// </remarks>
|
||||
@@ -114,7 +114,7 @@ public class DebugStreamService : IDisposable
|
||||
instanceUniqueName = instance.UniqueName;
|
||||
siteIdentifier = site.SiteIdentifier;
|
||||
|
||||
// Single-endpoint site (arch-review 2026-08-19 P3-3, applying the same N9
|
||||
// Single-endpoint site (applying the same
|
||||
// degeneration SiteAlarmLiveCacheService.ResolveSiteAsync already uses): reuse the
|
||||
// sole configured endpoint for both slots, so the bridge actor's NodeA↔NodeB
|
||||
// failover flip degenerates to a reconnect against the same node. Requiring BOTH
|
||||
|
||||
@@ -11,10 +11,16 @@ public static class AuditWireBudget
|
||||
public const int InitialBytes = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Appends one row to a field-1 repeated-message collection if it fits. A row larger than the
|
||||
/// Appends one row to a repeated-message collection if it fits. A row larger than the
|
||||
/// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained
|
||||
/// locally and fail explicitly with their identity; they are never acknowledged or truncated.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The protobuf message type of the rows.</typeparam>
|
||||
/// <param name="rows">The repeated field to append to.</param>
|
||||
/// <param name="row">The row to append.</param>
|
||||
/// <param name="wireBytes">The running wire size of the batch; increased by the row's size when appended.</param>
|
||||
/// <param name="rowIdentity">Identity of the row, used in the error raised when it exceeds the hard ceiling.</param>
|
||||
/// <returns><c>true</c> when the row was appended; <c>false</c> when it does not fit in the current batch.</returns>
|
||||
public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity)
|
||||
where T : IMessage<T>
|
||||
{
|
||||
|
||||
@@ -268,7 +268,7 @@ public sealed class CentralChannelProvider : IDisposable
|
||||
return TimeSpan.FromTicks(scaled >= cap || scaled < 0 ? cap : scaled);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Stops the failback timer and disposes all cached gRPC channels.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
public static class CentralControlDtoMapper
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Notification Outbox (#21)
|
||||
// Notification Outbox
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary>
|
||||
@@ -216,7 +216,7 @@ public static class CentralControlDtoMapper
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Audit Log (#23) ingest — envelopes only; rows go through the existing mappers
|
||||
// Audit Log ingest — envelopes only; rows go through the existing mappers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
@@ -437,7 +437,7 @@ public static class CentralControlDtoMapper
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Health Monitoring (#11)
|
||||
// Health Monitoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary>
|
||||
@@ -559,7 +559,7 @@ public static class CentralControlDtoMapper
|
||||
dto.SiteAuditBacklog = snapshot;
|
||||
}
|
||||
|
||||
// Review finding M1: wrapper presence keeps null ("no data") distinct from empty.
|
||||
// Wrapper presence keeps null ("no data") distinct from empty.
|
||||
if (msg.SecretMirrorStatuses is { } mirrorStatuses)
|
||||
{
|
||||
var wrapper = new SecretMirrorStatusListDto();
|
||||
|
||||
@@ -277,21 +277,15 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
|
||||
return CentralControlDtoMapper.ToDto(ack);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Application heartbeat — <b>always answers OK</b>, even when this node is not ready.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
|
||||
/// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
|
||||
/// an error would be a self-inflicted outage on a purely informational signal). So this is
|
||||
/// the one RPC that does not go through <see cref="RequireReady"/>: a heartbeat arriving
|
||||
/// before the actor exists is logged and dropped, exactly as the actor itself drops one
|
||||
/// that arrives before <c>ICentralHealthAggregator</c> is resolvable. Liveness is still
|
||||
/// detected — the aggregator's offline timeout fires when the heartbeats stop landing.
|
||||
/// </remarks>
|
||||
/// <param name="request">The heartbeat.</param>
|
||||
/// <param name="context">The gRPC call context.</param>
|
||||
/// <returns>An empty reply, always.</returns>
|
||||
// Application heartbeat — always answers OK, even when this node is not ready.
|
||||
// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
|
||||
// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
|
||||
// an error would be a self-inflicted outage on a purely informational signal). So this is
|
||||
// the one RPC that does not go through RequireReady: a heartbeat arriving before the actor
|
||||
// exists is logged and dropped, exactly as the actor itself drops one that arrives before
|
||||
// ICentralHealthAggregator is resolvable. Liveness is still detected — the aggregator's
|
||||
// offline timeout fires when the heartbeats stop landing.
|
||||
/// <inheritdoc />
|
||||
public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context)
|
||||
{
|
||||
var central = _central;
|
||||
|
||||
@@ -8,14 +8,20 @@ public static class CentralControlSiteIdentity
|
||||
private static readonly object IdentityKey = new();
|
||||
|
||||
/// <summary>Stores an identity only after the site's credential has been verified.</summary>
|
||||
/// <param name="context">The call context to record the identity on.</param>
|
||||
/// <param name="siteId">The verified site identifier.</param>
|
||||
public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId;
|
||||
|
||||
/// <summary>Requires a credential-verified identity on the current call.</summary>
|
||||
/// <param name="context">The call context carrying the verified identity.</param>
|
||||
/// <returns>The verified site identifier.</returns>
|
||||
public static string RequireVerified(ServerCallContext context) =>
|
||||
context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site)
|
||||
? site : throw Denied();
|
||||
|
||||
/// <summary>Checks every ownership field before dispatching any part of a central control request.</summary>
|
||||
/// <param name="request">The inbound request whose ownership fields are checked.</param>
|
||||
/// <param name="context">The call context carrying the verified site identity.</param>
|
||||
public static void ValidateRequest(object request, ServerCallContext context)
|
||||
{
|
||||
var site = RequireVerified(context);
|
||||
|
||||
@@ -9,14 +9,20 @@ public static class GrpcRetrySafety
|
||||
public const string DispatchTrailer = "scadabridge-dispatch";
|
||||
|
||||
/// <summary>Creates a readiness rejection that proves no work was dispatched.</summary>
|
||||
/// <param name="detail">Human-readable rejection detail placed on the status.</param>
|
||||
/// <returns>An <see cref="RpcException"/> with <c>Unavailable</c> status and the not-dispatched trailer.</returns>
|
||||
public static RpcException NotDispatched(string detail) => new(
|
||||
new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } });
|
||||
|
||||
/// <summary>True only for an explicit rejection from the server before it dispatched work.</summary>
|
||||
/// <param name="error">The RPC failure to inspect.</param>
|
||||
/// <returns><c>true</c> when the trailer proves the server never dispatched the command.</returns>
|
||||
public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable
|
||||
&& error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started");
|
||||
|
||||
/// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary>
|
||||
/// <param name="error">The original RPC failure whose trailers are preserved.</param>
|
||||
/// <returns>An <see cref="RpcException"/> with <c>Aborted</c> status marking the outcome as unknown.</returns>
|
||||
public static RpcException OutcomeUnknown(RpcException error) => new(
|
||||
new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."),
|
||||
error.Trailers);
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
/// <para>
|
||||
/// <b>Per-call deadlines match today's Ask timeouts exactly</b> — see <see cref="ResolveDeadline"/>.
|
||||
/// An unknown/unconfigured site now fails fast: it surfaces to the caller as a
|
||||
/// <see cref="Status.Failure"/> immediately (WP1.6) instead of being warned-and-dropped to let the
|
||||
/// <see cref="Status.Failure"/> immediately instead of being warned-and-dropped to let the
|
||||
/// caller's Ask burn its full timeout. A transport fault takes the same <see cref="Status.Failure"/>
|
||||
/// path, which the S&F/audit layers already treat as transient.
|
||||
/// </para>
|
||||
@@ -83,7 +83,7 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
|
||||
}
|
||||
catch (SiteChannelUnavailableException ex)
|
||||
{
|
||||
// WP1.6: a known-dead send (no configured channel for this site) used to warn-and-drop,
|
||||
// A known-dead send (no configured channel for this site) used to warn-and-drop,
|
||||
// leaving the caller's Ask to burn its full timeout for a failure we already know about.
|
||||
// Fail fast instead — same Status.Failure completion path a transport fault takes below —
|
||||
// so S&F/audit treat it as transient immediately rather than tens of seconds later.
|
||||
@@ -130,6 +130,8 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
|
||||
}
|
||||
|
||||
/// <summary>Only read-only commands can replay after a response may have been lost.</summary>
|
||||
/// <param name="message">The command message about to be sent.</param>
|
||||
/// <returns><c>true</c> when the message is a read-only command safe to replay; otherwise <c>false</c>.</returns>
|
||||
internal static bool IsRetrySafe(object message) => message is
|
||||
DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
|
||||
or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest
|
||||
|
||||
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
/// Nullable <b>collections</b> are wrapped in a per-collection message, so
|
||||
/// the null/empty distinction becomes a message-presence question. proto3
|
||||
/// <c>repeated</c>/<c>map</c> alone collapses the two — precisely the silent
|
||||
/// data-loss class the transport round-trip guard caught in PLAN-05 T8.
|
||||
/// data-loss class the transport round-trip guard caught in the transport round-trip tests.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>Guid?</c> members ride as a <c>string</c> holding the "D" form, with
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
/// Raised when a site has no usable gRPC channel — an unknown site, or a site with neither
|
||||
/// <c>GrpcNodeAAddress</c> nor <c>GrpcNodeBAddress</c> configured. <see cref="GrpcSiteTransport"/>
|
||||
/// treats this as a known-dead send: it fails the caller's <c>Ask</c> fast with an
|
||||
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception (WP1.6) instead of warning and dropping
|
||||
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception instead of warning and dropping
|
||||
/// the message, so the caller does not burn its full Ask timeout on a failure already known at send
|
||||
/// time (central still never buffers).
|
||||
/// </summary>
|
||||
@@ -51,8 +51,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
private static readonly TimeSpan FailbackSweepInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// How long a replaced/removed channel is PARKED before it is disposed (arch-review
|
||||
/// 2026-08-19 P3-2). Active calls retain the channel beyond this minimum until completion.
|
||||
/// How long a replaced/removed channel is PARKED before it is disposed. Active calls retain the channel beyond this minimum until completion.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -366,7 +365,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Parks a channel that is no longer reachable through <see cref="_sites"/> instead of
|
||||
/// disposing it inline (arch-review 2026-08-19 P3-2).
|
||||
/// disposing it inline.
|
||||
/// <para>
|
||||
/// <b>Why deferred.</b> <see cref="ExecuteAsync{T}"/> reads the channel under
|
||||
/// <c>pair.Gate</c> but invokes the RPC OUTSIDE it, so an inline
|
||||
@@ -472,7 +471,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes every pooled channel and stops the failback sweep and parked-channel disposal.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lifecycleGate)
|
||||
@@ -500,6 +499,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
|
||||
private sealed class SitePair(string siteId)
|
||||
{
|
||||
/// <summary>The site identifier this pair of node channels belongs to.</summary>
|
||||
public string SiteId { get; } = siteId;
|
||||
public readonly object Gate = new();
|
||||
public string? NodeAEndpoint;
|
||||
@@ -515,12 +515,15 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
|
||||
private TimeSpan _backoff = InitialBackoff;
|
||||
|
||||
/// <summary>Sets the failback backoff to <paramref name="delay"/> and schedules the next probe that far ahead.</summary>
|
||||
/// <param name="delay">The delay before the next failback probe.</param>
|
||||
public void ScheduleFailback(TimeSpan delay)
|
||||
{
|
||||
_backoff = delay;
|
||||
NextProbeUtc = DateTime.UtcNow + _backoff;
|
||||
}
|
||||
|
||||
/// <summary>Doubles the failback backoff (capped at the maximum) and schedules the next probe.</summary>
|
||||
public void BumpFailbackBackoff()
|
||||
{
|
||||
var next = TimeSpan.FromTicks(Math.Min(_backoff.Ticks * 2, MaxBackoff.Ticks));
|
||||
@@ -528,6 +531,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
||||
NextProbeUtc = DateTime.UtcNow + _backoff;
|
||||
}
|
||||
|
||||
/// <summary>Resets the backoff to its initial value and clears the scheduled failback probe.</summary>
|
||||
public void ResetFailback()
|
||||
{
|
||||
_backoff = InitialBackoff;
|
||||
|
||||
@@ -122,6 +122,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// Creates a test-only instance that has no gRPC channel. Used to test
|
||||
/// Unsubscribe and Dispose behavior without needing a real endpoint.
|
||||
/// </summary>
|
||||
/// <param name="options">Optional communication options; defaults are used when null.</param>
|
||||
/// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns>
|
||||
internal static SiteStreamGrpcClient CreateForTesting(CommunicationOptions? options = null) => new(options ?? new CommunicationOptions());
|
||||
|
||||
@@ -146,8 +147,8 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is
|
||||
/// <b>cancelled only</b> — never disposed from here. Internal for testability.
|
||||
/// <para>
|
||||
/// <b>Cancel-only is the contract</b> (arch-review 2026-08-19 P3-1, the client half of the
|
||||
/// row-47 class): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
|
||||
/// <b>Cancel-only is the contract</b> (the client half of the
|
||||
/// shared-token-source disposal race): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
|
||||
/// still reads <c>cts.Token</c> (opening the call, <c>ReadAllAsync</c>, the
|
||||
/// <c>IsCancellationRequested</c> checks), so disposing it from the REPLACING path throws
|
||||
/// <see cref="ObjectDisposedException"/> inside the prior stream — and, worse, out of the
|
||||
@@ -172,7 +173,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// Cancels a subscription token source, swallowing <see cref="ObjectDisposedException"/>.
|
||||
/// A CTS whose owning stream loop has already unwound and disposed it is simply a
|
||||
/// subscription that no longer needs cancelling; the caller must never see that race as a
|
||||
/// failure (arch-review 2026-08-19 P3-1).
|
||||
/// failure.
|
||||
/// </summary>
|
||||
/// <param name="cts">The token source to cancel.</param>
|
||||
private static void CancelQuietly(CancellationTokenSource cts)
|
||||
@@ -249,7 +250,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
// This call OWNS `cts` — it created it, so it disposes it once the stream loop has
|
||||
// fully unwound (P3-1). No other path may dispose it while this one is still reading
|
||||
// fully unwound. No other path may dispose it while this one is still reading
|
||||
// cts.Token; a replacing subscription cancels only.
|
||||
try
|
||||
{
|
||||
@@ -270,15 +271,15 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// <summary>
|
||||
/// Opens the site-wide, <b>alarm-only</b> server-streaming subscription
|
||||
/// (<c>SubscribeSite</c>) for a whole site rather than a single instance. This is
|
||||
/// the central per-site feed that backs the aggregated Alarm Summary live cache
|
||||
/// (plan #10): the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
|
||||
/// the central per-site feed that backs the aggregated Alarm Summary live cache:
|
||||
/// the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
|
||||
/// per-instance filter and carries only <see cref="AlarmStateChanged"/> events for
|
||||
/// <em>all</em> instances on the site (attributes are deliberately excluded — the
|
||||
/// summary never shows them and they are far higher-volume).
|
||||
/// <para>
|
||||
/// The callback is deliberately <b>typed</b> as <see cref="AlarmStateChanged"/>
|
||||
/// rather than the generic <c>Action<object></c> used by
|
||||
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so Task 4's
|
||||
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so the
|
||||
/// per-site cache consumes an alarm delta directly with no downstream type test.
|
||||
/// The mapping is still shared via <see cref="ConvertToDomainEvent"/>; a non-alarm
|
||||
/// event (which should never appear on this stream) is defensively ignored rather
|
||||
@@ -504,7 +505,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// <summary>
|
||||
/// Cancels an active subscription by correlation ID. Cancel-only: the subscription's own
|
||||
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> call disposes the token
|
||||
/// source once its stream loop unwinds (P3-1). Cancelling an entry whose owner already
|
||||
/// source once its stream loop unwinds. Cancelling an entry whose owner already
|
||||
/// disposed it — or unsubscribing after the client was disposed — is a silent no-op.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Unique identifier of the subscription to cancel.</param>
|
||||
@@ -664,7 +665,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// here is synchronous (CTS cancellation and <see cref="GrpcChannel.Dispose"/>), so a
|
||||
/// synchronous <see cref="Dispose"/> can release everything without sync-over-async blocking.
|
||||
/// <para>
|
||||
/// Cancel-only, per-entry guarded (P3-1). The token sources are owned by their
|
||||
/// Cancel-only, per-entry guarded. The token sources are owned by their
|
||||
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> calls, which dispose them
|
||||
/// as their loops unwind in response to this cancellation; disposing them here raced those
|
||||
/// loops, and one <see cref="ObjectDisposedException"/> aborted the whole foreach — which in
|
||||
|
||||
@@ -131,7 +131,7 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
||||
/// <summary>
|
||||
/// Asynchronously disposes all cached clients and clears the cache. Each client's disposal
|
||||
/// is fault-isolated: one throwing client must not abort the loop and strand every later
|
||||
/// client's channel undisposed (arch-review 2026-08-19 P3-1).
|
||||
/// client's channel undisposed.
|
||||
/// </summary>
|
||||
/// <returns>A value task that completes when all clients have been disposed.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -39,7 +39,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
private volatile bool _shuttingDown;
|
||||
private long _actorCounter;
|
||||
// Ask timeout is 30 s. The ingest actor's repo
|
||||
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
|
||||
// calls take under 100 ms in steady state; a generous timeout absorbs a slow
|
||||
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
|
||||
// Shared with CentralCommunicationActor (same assembly) so the two audit-ingest
|
||||
// transports use one source of truth for the timeout.
|
||||
@@ -275,7 +275,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
// enriched AlarmStateUpdate, so it is reused unchanged.
|
||||
_streamSubscriber.SubscribeSiteAlarms,
|
||||
"site-wide alarms",
|
||||
// Its OWN, much larger channel (WP2.3): sharing the Debug View's 1000-slot
|
||||
// Its OWN, much larger channel: sharing the Debug View's 1000-slot
|
||||
// DropOldest meant an alarm burst during a WAN stall silently evicted operator-
|
||||
// visible transitions to make room for diagnostics traffic.
|
||||
_siteAlarmChannelCapacity,
|
||||
@@ -338,7 +338,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
//
|
||||
// The removal is claimed FIRST, but nothing is cancelled until the concurrency cap
|
||||
// has had its say, and a REPLACEMENT never consults the cap at all (arch-review
|
||||
// 2026-08-19 P3-5). Both halves matter:
|
||||
// 2026-08-19). Both halves matter:
|
||||
//
|
||||
// * A replacement is net-neutral on the stream count (one entry out, one back in),
|
||||
// so refusing it can only ever destroy capacity, never protect it. The old code
|
||||
@@ -402,7 +402,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
{
|
||||
// Lossy-under-backpressure is the debug-view spec; make the real
|
||||
// loss visible: first eviction + every 500th thereafter, plus a
|
||||
// per-kind counter on the node (WP2.3).
|
||||
// per-kind counter on the node.
|
||||
var n = Interlocked.Increment(ref dropped);
|
||||
Interlocked.Increment(ref _droppedStreamEvents);
|
||||
ScadaBridgeTelemetry.RecordSiteStreamEventDropped(streamKind);
|
||||
@@ -447,8 +447,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
"Stream {CorrelationId} started for {Description} (subscription {SubscriptionId})",
|
||||
correlationId, description, subscriptionId);
|
||||
|
||||
// Flush response headers NOW, while the relay is attached and before the first event
|
||||
// (WP2.3). This is the client's "the site accepted my subscription" signal — the
|
||||
// Flush response headers NOW, while the relay is attached and before the first event.
|
||||
// This is the client's "the site accepted my subscription" signal — the
|
||||
// per-site aggregator hangs its once-per-successful-reconnect re-seed off it. Without
|
||||
// an explicit flush, ASP.NET Core defers headers to the first written message, so a
|
||||
// quiet site would never report connected. Best-effort: a client that vanished between
|
||||
@@ -516,12 +516,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retained wire method; ingest is served only by CentralControlService.</summary>
|
||||
/// <inheritdoc />
|
||||
public override Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context)
|
||||
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
|
||||
StatusCode.Unimplemented, "Use CentralControlService.IngestAuditEvents on central.")));
|
||||
|
||||
/// <summary>Retained wire method; combined ingest is served only by CentralControlService.</summary>
|
||||
/// <inheritdoc />
|
||||
public override Task<IngestAck> IngestCachedTelemetry(CachedTelemetryBatch request, ServerCallContext context)
|
||||
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
|
||||
StatusCode.Unimplemented, "Use CentralControlService.IngestCachedTelemetry on central.")));
|
||||
@@ -535,12 +535,11 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
/// <summary>Flow Debugger PullTraceEvents: server-side id cap, matching the site queue's.</summary>
|
||||
public const int MaxTraceExecutionIdsPerPull = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
|
||||
/// <c>ExecutionId</c> or <c>ParentExecutionId</c> is one of the requested ids. NOT a
|
||||
/// reconciliation path — moves no cursor, flips no ForwardState. Gated by the same
|
||||
/// <c>ControlPlaneAuthInterceptor</c> prefix as <see cref="PullAuditEvents"/>.
|
||||
/// </summary>
|
||||
// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
|
||||
// ExecutionId or ParentExecutionId is one of the requested ids. NOT a reconciliation
|
||||
// path — moves no cursor, flips no ForwardState. Gated by the same
|
||||
// ControlPlaneAuthInterceptor prefix as PullAuditEvents.
|
||||
/// <inheritdoc />
|
||||
public override async Task<PullTraceEventsResponse> PullTraceEvents(
|
||||
PullTraceEventsRequest request,
|
||||
ServerCallContext context)
|
||||
@@ -630,12 +629,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc)
|
||||
: DateTime.MinValue;
|
||||
|
||||
// Composite-keyset cursor (WP2.3), mirroring PullSiteCalls: proto3
|
||||
// Composite-keyset cursor, mirroring PullSiteCalls: proto3
|
||||
// defaults an unset string to "", which the queue treats as "no cursor"
|
||||
// (legacy inclusive >= behaviour), so an older central is unaffected.
|
||||
var afterId = string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId;
|
||||
|
||||
// AT-LEAST-ONCE (WP2.3). The incoming cursor is central's receipt for
|
||||
// AT-LEAST-ONCE. The incoming cursor is central's receipt for
|
||||
// everything at or before it — that is the ONLY proof the site accepts.
|
||||
// Rows are flipped to Reconciled here, at the START of the NEXT pull,
|
||||
// instead of right after the previous response was projected: a fault
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Central, transient, in-memory per-site live alarm cache (plan #10, Task 4).
|
||||
/// Central, transient, in-memory per-site live alarm cache.
|
||||
/// A DI singleton shared by every Alarm Summary Blazor circuit on the active central
|
||||
/// node. For each site with at least one active viewer it runs ONE shared per-site
|
||||
/// aggregator that seeds from the existing debug-snapshot fan-out and stays warm on a
|
||||
@@ -55,8 +55,8 @@ public interface ISiteAlarmLiveCache
|
||||
|
||||
/// <summary>
|
||||
/// Whether the site's aggregator has completed its initial seed and is serving live
|
||||
/// state. The page can use this to decide whether to keep its 15s poll fallback
|
||||
/// (Task 5): <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
|
||||
/// state. The page can use this to decide whether to keep its 15s poll fallback:
|
||||
/// <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
|
||||
/// cache is authoritative.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The numeric site id.</param>
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
|
||||
sp.GetService<ISitePskProvider>()));
|
||||
services.AddSingleton<DebugStreamService>();
|
||||
|
||||
// Aggregated live alarm cache (plan #10, Task 4): transient, in-memory, shared
|
||||
// Aggregated live alarm cache transient, in-memory, shared
|
||||
// per-site aggregator backing the operator Alarm Summary page. Singleton so every
|
||||
// Blazor circuit shares one aggregator per site; started/stopped by viewer
|
||||
// reference-count. No persistence (locked [PERM]: no central alarm store).
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ISiteAlarmLiveCache"/> — the DI singleton that owns the shared,
|
||||
/// reference-counted, transient per-site live alarm cache (plan #10, Task 4). For each
|
||||
/// reference-counted, transient per-site live alarm cache. For each
|
||||
/// site with active Alarm Summary viewers it runs ONE
|
||||
/// <see cref="SiteAlarmAggregatorActor"/>, seeded by the existing debug-snapshot fan-out
|
||||
/// (<see cref="CommunicationService.RequestDebugSnapshotAsync"/>) and kept warm on a
|
||||
@@ -246,7 +246,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
TimeSpan.FromSeconds(5), // reconnect delay — former ReconnectDelay static default
|
||||
TimeSpan.FromSeconds(60), // stability window — former StabilityWindow static default
|
||||
_options.LiveAlarmCacheReconcileJitterFraction,
|
||||
// Open-attempt deadline (P3-4). Passed explicitly rather than defaulted:
|
||||
// Open-attempt deadline. Passed explicitly rather than defaulted:
|
||||
// Props.Create takes an expression tree, which may not omit an optional
|
||||
// argument (CS0854).
|
||||
SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout));
|
||||
@@ -426,9 +426,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Alarms-only (WP2.3): this fan-out discards snapshot.AttributeValues, so
|
||||
// Alarms-only: this fan-out discards snapshot.AttributeValues, so
|
||||
// asking the site to build and ship them is pure wire waste. The flag is
|
||||
// additive on the wire; a pre-WP2.3 site ignores it and returns the full
|
||||
// additive on the wire; an older site ignores it and returns the full
|
||||
// snapshot, which this loop reads exactly as before.
|
||||
var request = new DebugSnapshotRequest(
|
||||
instanceUniqueName, Guid.NewGuid().ToString("N"), AlarmsOnly: true);
|
||||
@@ -470,7 +470,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
entry.HasPublished = true;
|
||||
// Liveness is the STREAM's, not the cache's: a completed or given-up stream must
|
||||
// stop reporting live between reopen ticks, or the page grafts a freezing snapshot
|
||||
// over fresh poll data (WP2.3 carried residual).
|
||||
// over fresh poll data.
|
||||
entry.StreamLive = streamLive;
|
||||
subscribers = entry.Subscribers.ToArray();
|
||||
}
|
||||
@@ -496,6 +496,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
|
||||
private sealed class AggregatorTerminationWatcher : ReceiveActor
|
||||
{
|
||||
/// <summary>Creates the watcher and begins watching the aggregator.</summary>
|
||||
/// <param name="watched">The aggregator actor to watch.</param>
|
||||
/// <param name="onTerminated">Callback invoked once when the watched actor terminates.</param>
|
||||
public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
|
||||
{
|
||||
Context.Watch(watched);
|
||||
@@ -551,11 +554,20 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
/// <summary>Per-site bookkeeping. All mutable fields are guarded by the service's <c>_lock</c>.</summary>
|
||||
private sealed class SiteEntry
|
||||
{
|
||||
/// <summary>Creates the bookkeeping entry for a site.</summary>
|
||||
/// <param name="siteId">The numeric site id.</param>
|
||||
public SiteEntry(int siteId) => SiteId = siteId;
|
||||
|
||||
/// <summary>The numeric site id this entry tracks.</summary>
|
||||
public int SiteId { get; }
|
||||
|
||||
/// <summary>Active viewer subscriptions for the site.</summary>
|
||||
public List<Subscription> Subscribers { get; } = new();
|
||||
|
||||
/// <summary>The site's shared aggregator actor, or <c>null</c> when none is running.</summary>
|
||||
public IActorRef? Actor { get; set; }
|
||||
|
||||
/// <summary>True while an aggregator start attempt is in progress.</summary>
|
||||
public bool Starting { get; set; }
|
||||
|
||||
/// <summary>Current published snapshot the page renders. Swapped as a whole immutable list.</summary>
|
||||
@@ -578,7 +590,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
/// </summary>
|
||||
public bool StreamLive { get; set; }
|
||||
|
||||
/// <summary>Timer that stops the aggregator after the last viewer leaves.</summary>
|
||||
public Timer? LingerTimer { get; set; }
|
||||
|
||||
/// <summary>Version counter that invalidates a stale linger-timer callback.</summary>
|
||||
public int LingerVersion { get; set; }
|
||||
|
||||
/// <summary>Bounded retry timer armed when a start attempt failed to create the actor.</summary>
|
||||
@@ -611,6 +626,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
{
|
||||
public static readonly NoOpSubscription Instance = new();
|
||||
private NoOpSubscription() { }
|
||||
/// <summary>Does nothing; the rejected subscription was never registered.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
@@ -620,6 +636,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
private readonly SiteAlarmLiveCacheService _owner;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Creates a subscription handle for a viewer.</summary>
|
||||
/// <param name="owner">The owning cache service.</param>
|
||||
/// <param name="siteId">The numeric site id subscribed to.</param>
|
||||
/// <param name="onChanged">Callback invoked when the site's cache changes.</param>
|
||||
public Subscription(SiteAlarmLiveCacheService owner, int siteId, Action onChanged)
|
||||
{
|
||||
_owner = owner;
|
||||
@@ -627,9 +647,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
|
||||
OnChanged = onChanged;
|
||||
}
|
||||
|
||||
/// <summary>The numeric site id subscribed to.</summary>
|
||||
public int SiteId { get; }
|
||||
|
||||
/// <summary>Callback invoked when the site's cache changes.</summary>
|
||||
public Action OnChanged { get; }
|
||||
|
||||
/// <summary>Unregisters this viewer from the owning cache; safe to call more than once.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
|
||||
// INCLUDE(Status) makes the audit KPI window query (count/aggregate by Status
|
||||
// over a recent OccurredAtUtc range) a covering index seek — the persisted
|
||||
// computed Status column rides along in the leaf without a key/RID lookup back
|
||||
// into the clustered index per matching row (arch-review WP1.4).
|
||||
// into the clustered index per matching row.
|
||||
builder.HasIndex(e => e.OccurredAtUtc)
|
||||
.IsDescending(true)
|
||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc")
|
||||
|
||||
+3
-4
@@ -74,7 +74,7 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
|
||||
// Covers ComputeKpisAsync's "DeliveredLastInterval" count
|
||||
// (Status == Delivered && DeliveredAt >= deliveredSince): filtered to just the
|
||||
// Delivered rows so the index stays small relative to the full table and the KPI
|
||||
// query can seek it directly instead of scanning every status (arch-review WP1.4).
|
||||
// query can seek it directly instead of scanning every status.
|
||||
// Status is stored as its enum name (HasConversion<string>() above), so the filter
|
||||
// predicate matches on the literal 'Delivered', not the underlying int value.
|
||||
// No N'' prefix: the value is ASCII-only (fine on SQL Server's nvarchar column)
|
||||
@@ -93,11 +93,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
|
||||
// SourceSiteId first, and IX_Notifications_Delivered is filtered to
|
||||
// Status='Delivered' only. So an unfiltered (or lightly filtered) page
|
||||
// fell back to a full clustered-index scan + sort on every COUNT + page
|
||||
// request (arch-review 2026-08-19 P5-3, register row-35 class). With both
|
||||
// request. With both
|
||||
// key columns descending, a forward scan of this index already delivers
|
||||
// rows in the requested order. Exact mirror of
|
||||
// SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId
|
||||
// (2026-08-19, arch-review P5-2).
|
||||
// SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId.
|
||||
builder.HasIndex(n => new { n.CreatedAt, n.NotificationId })
|
||||
.IsDescending(true, true)
|
||||
.HasDatabaseName("IX_Notifications_Created_NotificationId");
|
||||
|
||||
+2
-2
@@ -99,7 +99,7 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
|
||||
.IncludeProperties(nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status));
|
||||
|
||||
// Terminal backs the daily retention purge's predicate (TerminalAtUtc IS NOT NULL
|
||||
// AND TerminalAtUtc < cutoff) and Task 11's MIN() slice anchor: filtered to the
|
||||
// AND TerminalAtUtc < cutoff) and the MIN() slice anchor: filtered to the
|
||||
// terminal population so the purge seeks the expired tail instead of full-scanning
|
||||
// the 365-day table (arch-review 04 round 2, R6). Complements IX_SiteCalls_NonTerminal,
|
||||
// which is filtered to IS NULL and unusable for this predicate.
|
||||
@@ -114,7 +114,7 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
|
||||
// (Source_Created requires an equality on SourceSite first;
|
||||
// NonTerminal/Terminal are filtered to one side of TerminalAtUtc), so an
|
||||
// unfiltered page previously fell back to a full clustered-index scan +
|
||||
// sort (arch-review 2026-08-19 P5-2, register row-35 class).
|
||||
// sort.
|
||||
builder.HasIndex(s => new { s.CreatedAtUtc, s.TrackedOperationId })
|
||||
.IsDescending(true, true)
|
||||
.HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId");
|
||||
|
||||
@@ -10,7 +10,15 @@ public sealed class DataConnectionSecretConverter(IDataProtector protector) : Va
|
||||
{
|
||||
internal const string Prefix = "dp:protocol:v1:";
|
||||
internal const string Purpose = EncryptedStringConverter.ProtectorPurpose + ".DataConnection";
|
||||
/// <summary>Encrypts plaintext protocol JSON and tags it with the versioned prefix.</summary>
|
||||
/// <param name="protector">The data protector to encrypt with.</param>
|
||||
/// <param name="plaintext">The plaintext to protect.</param>
|
||||
/// <returns>The prefixed protected string.</returns>
|
||||
internal static string Protect(IDataProtector protector, string plaintext) => Prefix + protector.Protect(plaintext);
|
||||
/// <summary>Decrypts a stored, versioned protected value.</summary>
|
||||
/// <param name="protector">The data protector to decrypt with.</param>
|
||||
/// <param name="stored">The stored value, which must carry the versioned prefix.</param>
|
||||
/// <returns>The decrypted plaintext.</returns>
|
||||
internal static string Unprotect(IDataProtector protector, string stored)
|
||||
=> stored.StartsWith(Prefix, StringComparison.Ordinal)
|
||||
? protector.Unprotect(stored[Prefix.Length..])
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
public static class DataConnectionSecretUpgrade
|
||||
{
|
||||
/// <summary>Protects both configuration columns before repositories may materialize them.</summary>
|
||||
/// <param name="db">The configuration database context.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the upgrade.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public static async Task UpgradeAsync(ScadaBridgeDbContext db, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = db.Database.GetDbConnection();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user