diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index 719b2b6b..15f2966c 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -216,6 +216,7 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per- | `SubscribeBatchDelay` | 50ms | Delay BETWEEN reconnect re-subscribe chunks. Not applied on the instance-driven subscribe path — the Deployment Manager already staggers instance startup there. | | `QualityFlushInterval` | 1s | Coalescing window for pushing tag-quality counters to the health collector | | `MxSupervisoryAdviseParallelism` | 16 | Max in-flight supervisory advise commands on the MxGateway bulk-subscribe path when the endpoint has no write-user context | +| `MxAlarmStreamReopenDebounce` | 200ms | Window over which MxGateway alarm subscribes are coalesced into ONE reopen of the shared alarm stream (0–5 s; `0` = reopen per subscribe, the pre-2026-10-21 behaviour). Every new source still receives a fresh standing-state snapshot. | ## Subscription Management @@ -321,7 +322,7 @@ The registered `SourceReference` is a **NodeId** for OPC UA (the picker, CSV and - **Reconnect handling**: on entering **Reconnecting**, the actor pushes a `NativeAlarmSourceUnavailable` to every alarm subscriber (consumers mark mirrored alarms uncertain rather than clearing them). On successful reconnection it re-subscribes the feed; the adapter re-emits a snapshot, reconciling state. - **Shared condition filter (last-subscriber-wins)**: because the feed is opened once per source, it carries a single condition filter. A second subscriber that registers a *different* filter for the same source overwrites it (last writer wins) and the actor logs a warning — co-subscribers to one source are expected to agree on the filter. - **Routing index**: subscribed sources are bucketed by the **first path segment** of their reference (everything before the first `.`), and a transition is matched only against the bucket for its own first segment, plus a residue list of sources that carry no separator at all (a prefix shorter than one segment, which can match other buckets). This is sound because a source reference containing a separator can only prefix a reference sharing its entire first segment. The per-source `StartsWith` test and the condition-type gate inside the bucket are unchanged, so routing decisions are identical to the previous linear scan over every subscribed source; snapshot boundaries bypass the condition filter, but OPC UA boundaries are sent only to the exact source binding. Connection-wide Mx boundaries broadcast to all subscribers while preserving their refresh epoch. -- **Gateway union filter (MxGateway)**: `StreamAlarms` carries a single `alarm_filter_prefix`, so the adapter opens the stream on the **longest common prefix** of the currently subscribed source references and restarts it for each new source, including one covered by the existing prefix, so every newly bound source receives standing state. Each transport refresh has connection-wide start/end markers and a distinct epoch. An unsubscribe never restarts the stream — the prefix it leaves behind is at worst too broad, which may increase bandwidth. Correctness also depends on the epoch-scoped snapshot cache merging concurrent live updates and rejecting stale snapshot ends (DC-5); a broad filter alone provides no such guarantee. The prefix is a bandwidth optimisation only; the actor's per-source + condition-type gate remains authoritative, mirroring the OPC UA server-side WhereClause stance. +- **Gateway union filter (MxGateway)**: `StreamAlarms` carries a single `alarm_filter_prefix`, so the adapter opens the stream on the **longest common prefix** of the currently subscribed source references and restarts it for each new source, including one covered by the existing prefix, so every newly bound source receives standing state. Restarts are **coalesced** (review 2026-10-21): subscribes arriving within `MxAlarmStreamReopenDebounce` (200 ms) of the first share ONE restart on the union prefix, so a staggered deploy batch of N alarm-bound instances costs one close/reopen and one connection-wide snapshot replay instead of N (each of which was broadcast to every already-subscribed source — O(N·M) boundary messages). A reconnect, teardown or last unsubscribe invalidates a pending restart. Each transport refresh has connection-wide start/end markers and a distinct epoch. An unsubscribe never restarts the stream — the prefix it leaves behind is at worst too broad, which may increase bandwidth. Correctness also depends on the epoch-scoped snapshot cache merging concurrent live updates and rejecting stale snapshot ends (DC-5); a broad filter alone provides no such guarantee. The prefix is a bandwidth optimisation only; the actor's per-source + condition-type gate remains authoritative, mirroring the OPC UA server-side WhereClause stance. ### Protocol-Neutral Types & Messages @@ -468,4 +469,4 @@ The DCL reports the following metrics to the Health Monitoring component via the OPC UA connection and endpoint verification select exactly the configured security mode. If a protected mode is unavailable, or discovery fails while protection was requested, the operation fails with the requested mode in its diagnostic; it never falls back to an unsecured session. -DCL retains each subscribed alarm source's current standing state and its in-progress snapshot. A new subscriber sharing a feed receives a scoped replay, or the already-received prefix of an in-progress frame followed by the remaining frame. Cache state is discarded on reconnect and last-owner unsubscribe. A new Mx source reopens the shared stream even when the existing prefix covers it, obtaining a fresh standing-state snapshot; callbacks from the canceled stream are ignored. Mx refresh boundaries remain connection-wide but share a distinct per-stream epoch. Both replay cache and runtime mirror ignore stale-epoch rows and apply newer live raises/clears over a concurrent refresh before committing it. The OPC UA client's per-condition `(active, acked)` memory used to derive transition kinds follows the same removal rule as the replay cache — a condition that returns to inactive + acknowledged (the default prior state) is forgotten — so it stays bounded on servers whose condition names carry a per-occurrence component (review 2026-10-21). +DCL retains each subscribed alarm source's current standing state and its in-progress snapshot. A new subscriber sharing a feed receives a scoped replay, or the already-received prefix of an in-progress frame followed by the remaining frame. Cache state is discarded on reconnect and last-owner unsubscribe. A new Mx source reopens the shared stream (coalesced with any other new sources inside the reopen window) even when the existing prefix covers it, obtaining a fresh standing-state snapshot; callbacks from the canceled stream are ignored. Mx refresh boundaries remain connection-wide but share a distinct per-stream epoch. Both replay cache and runtime mirror ignore stale-epoch rows and apply newer live raises/clears over a concurrent refresh before committing it. The OPC UA client's per-condition `(active, acked)` memory used to derive transition kinds follows the same removal rule as the replay cache — a condition that returns to inactive + acknowledged (the default prior state) is forgotten — so it stays bounded on servers whose condition names carry a per-occurrence component (review 2026-10-21). diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs index 356f3a5c..063f0e7a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs @@ -50,6 +50,17 @@ public class MxGatewayDataConnection private readonly Dictionary _alarmSubSources = new(StringComparer.Ordinal); private string _alarmStreamPrefix = string.Empty; + // Reopen coalescing (review 2026-10-21 02/P3). Every new source needs a fresh standing-state + // snapshot, but a burst of subscribes (a staggered deploy batch of alarm-bound instances) + // used to cost one gateway stream close/reopen — and one connection-wide snapshot replay to + // every alarm subscriber — PER source. Subscribes inside the window share ONE reopen on the + // union prefix. The ticket invalidates a scheduled reopen that a reconnect/teardown or a + // last-unsubscribe superseded. All guarded by _alarmLock. + private readonly TimeSpan _alarmStreamReopenDebounce; + private AlarmTransitionCallback? _alarmCallback; + private int _alarmReopenTicket; + private bool _alarmReopenPending; + // subscriptionId → (tagPath, callback) so the event loop can route updates by tag, // plus tagPath → subscriptionId for reverse lookup. Concurrent because the event // loop reads from a background thread while Subscribe/Unsubscribe mutate. @@ -80,6 +91,11 @@ public class MxGatewayDataConnection /// Maximum in-flight supervisory advise commands on the bulk-subscribe path when the /// endpoint has no write-user context (DataConnectionOptions.MxSupervisoryAdviseParallelism). /// + /// + /// Window over which alarm subscribes are coalesced into one stream reopen + /// (DataConnectionOptions.MxAlarmStreamReopenDebounce). Zero reopens synchronously + /// per subscribe (the historical behaviour). Null uses the option's default. + /// /// /// Gateway client registration name used when the connection's own ClientName is blank /// (ScadaBridge:MxGateway:ClientNamePrefix, default scadabridge). Null or blank @@ -90,8 +106,11 @@ public class MxGatewayDataConnection ILogger logger, ISecretResolver? secretResolver = null, int supervisoryAdviseParallelism = 16, - string? clientNamePrefix = null) + string? clientNamePrefix = null, + TimeSpan? alarmStreamReopenDebounce = null) { + var debounce = alarmStreamReopenDebounce ?? new DataConnectionOptions().MxAlarmStreamReopenDebounce; + _alarmStreamReopenDebounce = debounce < TimeSpan.Zero ? TimeSpan.Zero : debounce; _clientFactory = clientFactory; _logger = logger; _secretResolver = secretResolver; @@ -173,6 +192,7 @@ public class MxGatewayDataConnection _alarmSubCount = 0; _alarmSubSources.Clear(); _alarmStreamPrefix = string.Empty; + CancelPendingAlarmReopenLocked(); } // New session: the previous session's items died with it. Drop their routing entries @@ -311,6 +331,7 @@ public class MxGatewayDataConnection _alarmSubCount = 0; _alarmSubSources.Clear(); _alarmStreamPrefix = string.Empty; + CancelPendingAlarmReopenLocked(); } if (_client is not null) await _client.DisconnectAsync(cancellationToken); @@ -399,51 +420,88 @@ public class MxGatewayDataConnection { _alarmSubCount++; _alarmSubSources[subscriptionId] = sourceReference; + // The DataConnectionActor routes every transition by source reference, so the one + // shared feed needs a single callback; the most recent registration serves. + _alarmCallback = callback; // Push the UNION of the subscribed source references to the gateway as the // stream's single filter prefix. It is a bandwidth optimisation only — the // MxGateway has no server-side CONDITION filter either, so conditionFilter is // still not forwarded: the DataConnectionActor's per-source + per-condition-type // gate stays authoritative, uniform with the OPC UA WhereClause stance. - // Even a covered new source needs a standing-state snapshot. Reopen - // the shared stream; DCL separately replays same-source co-subscribers. - - // A new source outside the live prefix (or the first subscriber): (re)open the - // stream on the recomputed union. The source replays a fresh snapshot, which - // NativeAlarmActor already handles. - if (_alarmCts != null) + // Even a covered new source needs a standing-state snapshot, so every new source + // (re)opens the shared stream — coalesced across a burst (see the fields above); + // DCL separately replays same-source co-subscribers. + if (_alarmStreamReopenDebounce <= TimeSpan.Zero) { - _alarmCts.Cancel(); - _alarmCts.Dispose(); + ReopenAlarmStreamLocked(); } - - _alarmStreamPrefix = AlarmFilterPrefix.LongestCommonPrefix(_alarmSubSources.Values); - _alarmCts = new CancellationTokenSource(); - var token = _alarmCts.Token; - var client = _client!; - var prefix = _alarmStreamPrefix.Length == 0 ? null : _alarmStreamPrefix; - var epoch = Guid.NewGuid(); - void Forward(Commons.Types.Alarms.NativeAlarmTransition transition) + else if (!_alarmReopenPending) { - lock (_alarmLock) + _alarmReopenPending = true; + var ticket = ++_alarmReopenTicket; + _ = Task.Delay(_alarmStreamReopenDebounce).ContinueWith(_ => { - if (token.IsCancellationRequested) return; - if (transition.Kind == AlarmTransitionKind.SnapshotStart) epoch = Guid.NewGuid(); - callback(transition.Kind is AlarmTransitionKind.SnapshotStart or AlarmTransitionKind.Snapshot or AlarmTransitionKind.SnapshotComplete - ? transition with { SnapshotId = epoch } : transition); - } + lock (_alarmLock) + { + if (ticket != _alarmReopenTicket || !_alarmReopenPending) + return; // superseded by a reconnect, teardown or last unsubscribe + _alarmReopenPending = false; + if (_alarmSubCount > 0 && _client is not null) + ReopenAlarmStreamLocked(); + } + }, TaskScheduler.Default); } - _ = Task.Run(async () => - { - Forward(MxGatewayAlarmMapper.SnapshotComplete() with { Kind = AlarmTransitionKind.SnapshotStart }); - if (token.IsCancellationRequested) return; - await client.RunAlarmStreamAsync(prefix, Forward, token); - }, token); } return Task.FromResult(subscriptionId); } + /// + /// (Re)opens the shared alarm stream on the union prefix of the subscribed sources. The + /// source replays a fresh snapshot, which NativeAlarmActor already handles. Callers hold + /// . + /// + private void ReopenAlarmStreamLocked() + { + if (_alarmCts != null) + { + _alarmCts.Cancel(); + _alarmCts.Dispose(); + } + + _alarmStreamPrefix = AlarmFilterPrefix.LongestCommonPrefix(_alarmSubSources.Values); + _alarmCts = new CancellationTokenSource(); + var token = _alarmCts.Token; + var client = _client!; + var callback = _alarmCallback!; + var prefix = _alarmStreamPrefix.Length == 0 ? null : _alarmStreamPrefix; + var epoch = Guid.NewGuid(); + void Forward(Commons.Types.Alarms.NativeAlarmTransition transition) + { + lock (_alarmLock) + { + if (token.IsCancellationRequested) return; + if (transition.Kind == AlarmTransitionKind.SnapshotStart) epoch = Guid.NewGuid(); + callback(transition.Kind is AlarmTransitionKind.SnapshotStart or AlarmTransitionKind.Snapshot or AlarmTransitionKind.SnapshotComplete + ? transition with { SnapshotId = epoch } : transition); + } + } + _ = Task.Run(async () => + { + Forward(MxGatewayAlarmMapper.SnapshotComplete() with { Kind = AlarmTransitionKind.SnapshotStart }); + if (token.IsCancellationRequested) return; + await client.RunAlarmStreamAsync(prefix, Forward, token); + }, token); + } + + /// Invalidates a scheduled coalesced reopen. Callers hold . + private void CancelPendingAlarmReopenLocked() + { + _alarmReopenPending = false; + _alarmReopenTicket++; + } + /// public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default) { @@ -464,6 +522,7 @@ public class MxGatewayDataConnection _alarmCts?.Dispose(); _alarmCts = null; _alarmStreamPrefix = string.Empty; + CancelPendingAlarmReopenLocked(); } } return Task.CompletedTask; @@ -541,6 +600,7 @@ public class MxGatewayDataConnection _alarmSubCount = 0; _alarmSubSources.Clear(); _alarmStreamPrefix = string.Empty; + CancelPendingAlarmReopenLocked(); } if (_client is not null) await _client.DisposeAsync(); diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs index ad16e437..1db30f26 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs @@ -46,10 +46,11 @@ public class DataConnectionFactory : IDataConnectionFactory _loggerFactory = loggerFactory; _secretResolver = secretResolver; var globalOptions = opcUaGlobalOptions.Value; - // Only the MxGateway supervisory-advise window is adapter-level today; the rest of + // Only the MxGateway supervisory-advise window and alarm-reopen debounce are adapter-level; the rest of // DataConnectionOptions is consumed by the actor, which is constructed elsewhere. - var supervisoryAdviseParallelism = - (dataConnectionOptions?.Value ?? new DataConnectionOptions()).MxSupervisoryAdviseParallelism; + var dcOptions = dataConnectionOptions?.Value ?? new DataConnectionOptions(); + var supervisoryAdviseParallelism = dcOptions.MxSupervisoryAdviseParallelism; + var alarmStreamReopenDebounce = dcOptions.MxAlarmStreamReopenDebounce; // The client registration name a connection with a blank ClientName falls back to // (review 2026-10-21 02/U1 — the option was bound and validated but never read). var mxClientNamePrefix = (mxGatewayGlobalOptions?.Value ?? new MxGatewayGlobalOptions()).ClientNamePrefix; @@ -70,7 +71,8 @@ public class DataConnectionFactory : IDataConnectionFactory _loggerFactory.CreateLogger(), _secretResolver, supervisoryAdviseParallelism, - mxClientNamePrefix)); + mxClientNamePrefix, + alarmStreamReopenDebounce)); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs index bbc6bec2..21cac519 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs @@ -72,6 +72,14 @@ public class DataConnectionOptions /// public int MxSupervisoryAdviseParallelism { get; set; } = 16; + /// + /// Window over which MxGateway alarm subscribes are coalesced into ONE reopen of the shared, + /// gateway-wide alarm stream (review 2026-10-21 02/P3). Every new source still gets a fresh + /// standing-state snapshot — the reopen just serves the whole burst. Zero restores the + /// historical reopen-per-subscribe. Must be between 0 and 5 seconds. + /// + public TimeSpan MxAlarmStreamReopenDebounce { get; set; } = TimeSpan.FromMilliseconds(200); + /// Timeout for synchronous write operations to devices. public TimeSpan WriteTimeout { get; set; } = TimeSpan.FromSeconds(30); diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs index c74e4ed8..4b70abf6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs @@ -60,5 +60,9 @@ public sealed class DataConnectionOptionsValidator : OptionsValidatorBase 0, $"ScadaBridge:DataConnection:MxSupervisoryAdviseParallelism must be positive (was {options.MxSupervisoryAdviseParallelism})."); + + builder.RequireThat(options.MxAlarmStreamReopenDebounce >= TimeSpan.Zero + && options.MxAlarmStreamReopenDebounce <= TimeSpan.FromSeconds(5), + $"ScadaBridge:DataConnection:MxAlarmStreamReopenDebounce must be between 0 and 5 seconds (was {options.MxAlarmStreamReopenDebounce})."); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewaySessionScopedHandleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewaySessionScopedHandleTests.cs index 01ff1939..f2a1426f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewaySessionScopedHandleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewaySessionScopedHandleTests.cs @@ -123,6 +123,61 @@ public class MxGatewaySessionScopedHandleTests Assert.Equal(1, Volatile.Read(ref delivered)); } + /// + /// Review 2026-10-21 02/P3: a burst of alarm subscribes (a staggered deploy batch of N + /// alarm-bound instances) is coalesced into ONE stream reopen on the union prefix instead of N + /// close/reopen cycles, each with its own connection-wide snapshot replay. Every source in the + /// burst is still covered by that one fresh snapshot (DC-5's standing-state guarantee). + /// + [Fact] + public async Task AlarmSubscribeBurst_IsCoalescedIntoOneStreamReopen() + { + var fake = new FakeMxGatewayClient(); + var adapter = new MxGatewayDataConnection( + fake, NullLogger.Instance, + alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(300)); + await adapter.ConnectAsync(Details()); + + // Spaced like actor-delivered subscribes, so an un-coalesced reopen really reaches the + // gateway each time (a reopen superseded before its task runs never would). + foreach (var source in new[] { "Area1.Tank1", "Area1.Tank2", "Area1.Tank3", "Area1.Tank4", "Area1.Tank5" }) + { + await adapter.SubscribeAlarmsAsync(source, null, _ => { }); + await Task.Delay(20); + } + + await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count >= 1; }); + await Task.Delay(400); // well past the window: no trailing reopens + + lock (fake.AlarmStreamPrefixes) + { + Assert.Single(fake.AlarmStreamPrefixes); + Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[0]); + } + } + + /// + /// A subscribe arriving after the coalesced reopen has fired still gets its own fresh + /// snapshot — coalescing never swallows a source. + /// + [Fact] + public async Task AlarmSubscribe_AfterTheWindow_StillReopens() + { + var fake = new FakeMxGatewayClient(); + var adapter = new MxGatewayDataConnection( + fake, NullLogger.Instance, + alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(50)); + await adapter.ConnectAsync(Details()); + + await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { }); + await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 1; }); + await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { }); + await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 2; }); + + lock (fake.AlarmStreamPrefixes) + Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]); + } + private static async Task WaitUntil(Func condition, int timeoutMs = 30_000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs index 3d2450a6..447a77eb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs @@ -39,4 +39,27 @@ public class DataConnectionOptionsValidatorTests Assert.True(result.Failed); Assert.Contains("SeedReadMaxAttempts", result.FailureMessage); } + + /// Verifies that an out-of-range MxGateway alarm-reopen debounce is rejected (review 2026-10-21 02/P3). + /// The debounce under test. + [Theory] + [InlineData(-1)] + [InlineData(5001)] + public void OutOfRangeMxAlarmStreamReopenDebounce_IsRejected(int milliseconds) + { + var result = Validate(new DataConnectionOptions + { + MxAlarmStreamReopenDebounce = TimeSpan.FromMilliseconds(milliseconds) + }); + Assert.True(result.Failed); + Assert.Contains("MxAlarmStreamReopenDebounce", result.FailureMessage); + } + + /// Verifies that a zero debounce (reopen per subscribe) is accepted. + [Fact] + public void ZeroMxAlarmStreamReopenDebounce_IsAccepted() + { + var result = Validate(new DataConnectionOptions { MxAlarmStreamReopenDebounce = TimeSpan.Zero }); + Assert.True(result.Succeeded, result.FailureMessage); + } }