fix(review): resolve deferred code-review findings; S7.Cli verdict, browser CP release, MemberLeft/Exited/Downed, Sql Detach hoist (#625)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -158,15 +158,35 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
set.Add(up.Member);
|
||||
}
|
||||
break;
|
||||
// Cluster-002: a leaving / exiting / downed member is no longer a usable member, so
|
||||
// drop it as soon as that transition lands rather than waiting for MemberRemoved
|
||||
// (several seconds later). MembersWithRole now has a production caller (the
|
||||
// historization scope-peer check), which must not defer to a departing partner.
|
||||
// Member equality is by UniqueAddress, so the status change does not matter here.
|
||||
case ClusterEvent.MemberLeft left:
|
||||
RemoveMember(left.Member);
|
||||
break;
|
||||
case ClusterEvent.MemberExited exited:
|
||||
RemoveMember(exited.Member);
|
||||
break;
|
||||
case ClusterEvent.MemberDowned downed:
|
||||
RemoveMember(downed.Member);
|
||||
break;
|
||||
case ClusterEvent.MemberRemoved removed:
|
||||
foreach (var role in removed.Member.Roles)
|
||||
if (_membersByRole.TryGetValue(role, out var set))
|
||||
set.Remove(removed.Member);
|
||||
RemoveMember(removed.Member);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _lock.
|
||||
private void RemoveMember(Member member)
|
||||
{
|
||||
foreach (var role in member.Roles)
|
||||
if (_membersByRole.TryGetValue(role, out var set))
|
||||
set.Remove(member);
|
||||
}
|
||||
|
||||
/// <summary>Handles a role leader change event.</summary>
|
||||
/// <param name="evt">The role leader changed event from the cluster.</param>
|
||||
internal void HandleRoleLeaderChanged(ClusterEvent.RoleLeaderChanged evt)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
@@ -51,8 +52,14 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
// Derive the headline verdict from BOTH the driver state AND the probe-read
|
||||
// StatusCode so the operator never sees `Health: Healthy` over a Bad snapshot
|
||||
// line (Driver.S7.Cli-012; mirrors Modbus.Cli's ComputeVerdict).
|
||||
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
|
||||
|
||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||
await console.Output.WriteLineAsync($"CPU: {CpuType} rack={Rack} slot={Slot}");
|
||||
await console.Output.WriteLineAsync($"Verdict: {verdict}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
@@ -77,4 +84,28 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combine the driver-side <see cref="DriverState"/> with the probe snapshot's OPC UA
|
||||
/// <c>StatusCode</c> into a single headline verdict: <c>FAIL</c> when the driver is not
|
||||
/// Healthy or the snapshot is Bad, <c>DEGRADED</c> when Healthy but Uncertain, else <c>OK</c>.
|
||||
/// </summary>
|
||||
/// <param name="state">The driver's health state.</param>
|
||||
/// <param name="statusCode">The OPC UA status code from the probe read.</param>
|
||||
/// <returns>A string describing the verdict.</returns>
|
||||
public static string ComputeVerdict(DriverState state, uint statusCode)
|
||||
{
|
||||
// Top 2 bits encode the quality class: 00 Good, 01 Uncertain, 10/11 Bad.
|
||||
var qualityClass = statusCode & 0xC0000000u;
|
||||
var snapshotGood = qualityClass == 0x00000000u;
|
||||
var snapshotUncertain = qualityClass == 0x40000000u;
|
||||
|
||||
if (state != DriverState.Healthy || !snapshotGood && !snapshotUncertain)
|
||||
return $"FAIL (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
|
||||
|
||||
if (snapshotUncertain)
|
||||
return $"DEGRADED (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
|
||||
|
||||
return $"OK (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -104,11 +104,31 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
var cp = result.ContinuationPoint;
|
||||
while (cp is { Length: > 0 })
|
||||
{
|
||||
var next = await _session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: false,
|
||||
continuationPoints: [cp],
|
||||
ct: ct).ConfigureAwait(false);
|
||||
BrowseNextResponse next;
|
||||
try
|
||||
{
|
||||
next = await _session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: false,
|
||||
continuationPoints: [cp],
|
||||
ct: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Caller cancelled mid-pagination (Driver.OpcUaClient.Browser-002): release the
|
||||
// server-side cursor on a fresh token (the original is already cancelled).
|
||||
// Best-effort — mirrors OpcUaClientDriver's pagination loop.
|
||||
try
|
||||
{
|
||||
await _session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: true,
|
||||
continuationPoints: [cp],
|
||||
ct: CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort — server may have already cleaned up */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (next.Results.Count == 0) break;
|
||||
var nextResult = next.Results[0];
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper for work the driver stops waiting on (a timed-out liveness probe or poll group).
|
||||
/// </summary>
|
||||
internal static class AbandonedTask
|
||||
{
|
||||
/// <summary>
|
||||
/// Observes an abandoned task's eventual fault so it never surfaces as an unobserved task
|
||||
/// exception. The task still owns — and disposes — its own connection, and still releases any
|
||||
/// concurrency slot when it finally completes; that is what keeps a frozen database from
|
||||
/// accumulating connections.
|
||||
/// </summary>
|
||||
/// <param name="work">The task being abandoned.</param>
|
||||
internal static void Detach(Task work)
|
||||
=> _ = work.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
@@ -1048,7 +1048,7 @@ public sealed class SqlDriver
|
||||
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
|
||||
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
|
||||
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
|
||||
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
|
||||
/// its own connection and disposes it; <see cref="AbandonedTask.Detach"/> observes its eventual fault.</para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The work's result type.</typeparam>
|
||||
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
|
||||
@@ -1089,13 +1089,13 @@ public sealed class SqlDriver
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Detach(running);
|
||||
AbandonedTask.Detach(running);
|
||||
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Our deadline fired and the provider DID honour the linked token.
|
||||
Detach(running);
|
||||
AbandonedTask.Detach(running);
|
||||
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
|
||||
}
|
||||
}
|
||||
@@ -1123,19 +1123,6 @@ public sealed class SqlDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
|
||||
/// task exception. The task still owns — and disposes — its own connection.
|
||||
/// <para>TODO(M2): a verbatim duplicate of <c>SqlPollReader.Detach</c>; both live in Driver.Sql and
|
||||
/// could hoist to one internal helper. Left in place here to keep this change off the reader.</para>
|
||||
/// </summary>
|
||||
private static void Detach(Task work)
|
||||
=> _ = work.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
// ---- endpoint description ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -329,7 +329,7 @@ public sealed class SqlPollReader
|
||||
// Either the caller pulled the plug, or our deadline fired and the provider DID honour the
|
||||
// linked token. Both leave the work running with nobody to observe its fault.
|
||||
if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work);
|
||||
Detach(work);
|
||||
AbandonedTask.Detach(work);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -352,22 +352,10 @@ public sealed class SqlPollReader
|
||||
plan.Model, plan.Members[0].Table, plan.Members.Count,
|
||||
(int)_operationTimeout.TotalMilliseconds);
|
||||
|
||||
Detach(work);
|
||||
AbandonedTask.Detach(work);
|
||||
return GroupResult.TimedOut;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task
|
||||
/// exception. The task still owns its connection and still releases the concurrency slot when it
|
||||
/// finally completes — that is what keeps a frozen database from accumulating connections.
|
||||
/// </summary>
|
||||
private static void Detach(Task work)
|
||||
=> _ = work.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a connection, executes the plan's single command, and materialises the result set.
|
||||
/// <para>The rows are read into memory <b>before</b> the reader is disposed — the slice-back needs
|
||||
|
||||
@@ -191,7 +191,13 @@ internal static class HistoryPaging
|
||||
/// <param name="skip">How many ties at <paramref name="boundaryT"/> were already emitted on prior pages.</param>
|
||||
/// <param name="cap">The client's per-page cap (<c>NumValuesPerNode</c>); must be > 0 to make progress.</param>
|
||||
/// <param name="boundaryT">The single SourceTimestamp every tie in the cluster shares.</param>
|
||||
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
|
||||
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.
|
||||
/// OpcUaServer-003, accepted by design: the backend contract
|
||||
/// (<c>IHistoryProvider.ReadRawAsync</c>) treats <c>endUtc</c> as EXCLUSIVE, so the
|
||||
/// <c>next <= endUtc</c> test here is deliberately the conservative side — at worst one extra
|
||||
/// empty resume page (<c>[endUtc, endUtc)</c> → GoodNoData, no continuation point), never a
|
||||
/// dropped or duplicated sample. Tightening to <c><</c> would be correct only if the live
|
||||
/// backend's boundary is confirmed exclusive; it is not worth the opposite off-by-one risk.</param>
|
||||
/// <param name="sliceStart">The index into the cluster the emitted slice starts at (clamped to the count).</param>
|
||||
/// <param name="sliceCount">How many ties the emitted slice contains (may be <c>0</c> on a stale-skip self-heal).</param>
|
||||
/// <param name="nextStartUtc">The next page's inclusive start: <paramref name="boundaryT"/> while the
|
||||
|
||||
Reference in New Issue
Block a user