Files
UA-.NETStandard/tests/Opc.Ua.Redundancy.Server.Tests/Redundancy/DistributedPushConfigurationTransactionCoordinatorTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

654 lines
28 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2025: the concurrent-acquire tasks are always awaited (Task.WhenAll)
// before the coordinators they capture are disposed by the enclosing
// `await using`; the analyzer cannot prove the ordering across the local
// task variables. Disabled file-level for the suite.
#pragma warning disable CA2025
// CA2007: tests run without a SynchronizationContext; ConfigureAwait(false)
// adds noise without a behavioural benefit. Disabled file-level for the suite.
#pragma warning disable CA2007
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using NUnit.Framework;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Server;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Redundancy
{
/// <summary>
/// Two-replica unit tests for the distributed
/// <see cref="DistributedPushConfigurationTransactionCoordinator"/>: shared
/// lease acquisition, cross-replica exclusion, expiry/crash recovery,
/// renewal, and release on apply/cancel/reset. The background renew loop is
/// suppressed and reconciliation is driven deterministically with a
/// <see cref="FakeTimeProvider"/>.
/// </summary>
[TestFixture]
[Category("Distributed")]
[Parallelizable(ParallelScope.All)]
public sealed class DistributedPushConfigurationTransactionCoordinatorTests
{
private static readonly TimeSpan s_leaseDuration = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_renewInterval = TimeSpan.FromSeconds(10);
private ITelemetryContext m_telemetry = null!;
[OneTimeSetUp]
public void OneTimeSetUp()
{
m_telemetry = NUnitTelemetryContext.Create();
}
[Test]
public void ConstructorNullArgumentsThrow()
{
using var store = new InMemorySharedKeyValueStore();
var options = NewOptions("A");
var time = new FakeTimeProvider();
Assert.That(
() => new DistributedPushConfigurationTransactionCoordinator(
null, null!, options, m_telemetry, time),
Throws.ArgumentNullException);
Assert.That(
() => new DistributedPushConfigurationTransactionCoordinator(
null, store, null!, m_telemetry, time),
Throws.ArgumentNullException);
Assert.That(
() => new DistributedPushConfigurationTransactionCoordinator(
null, store, options, null!, time),
Throws.ArgumentNullException);
}
[Test]
public void OptionsValidateRejectsInconsistentIntervals()
{
var options = new DistributedPushConfigurationOptions
{
ReplicaId = "A",
LeaseDuration = TimeSpan.FromSeconds(10),
RenewInterval = TimeSpan.FromSeconds(10)
};
Assert.That(() => options.Validate(), Throws.ArgumentException);
}
[Test]
public async Task FirstReplicaAcquiresOwnershipAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
Assert.That(a.HoldsLease, Is.True);
}
[Test]
public async Task SecondReplicaRejectedWhileFirstHoldsLeaseAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await b.AcquireTransactionOwnershipAsync(Session("s2")));
Assert.That(ex.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadTransactionPending));
Assert.That(b.HoldsLease, Is.False);
}
[Test]
public async Task StandbyTakesOverAfterOwnerLeaseExpiresAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
// Simulate the owner crashing: it stops renewing and the lease
// expires. The renew loop is suppressed, so no renewal happens.
time.Advance(s_leaseDuration + TimeSpan.FromSeconds(1));
await b.AcquireTransactionOwnershipAsync(Session("s2"));
Assert.That(b.HoldsLease, Is.True);
}
[Test]
public async Task ReacquireRenewsLeaseAndKeepsStandbyOutAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
// The owner renews before the original expiry, extending it.
time.Advance(TimeSpan.FromSeconds(20));
await a.AcquireTransactionOwnershipAsync(Session("s1"));
// Past the original expiry but within the renewed window: the
// standby must still be refused.
time.Advance(TimeSpan.FromSeconds(15));
Assert.That(
async () => await b.AcquireTransactionOwnershipAsync(Session("s2")),
Throws.TypeOf<ServiceResultException>());
}
[Test]
public async Task ReconcileRenewsLeaseWhileTransactionActiveAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
a.Stage(Session("s1"), NoopOperation());
// The background loop would renew here; drive one reconcile pass
// deterministically while the transaction is active.
time.Advance(TimeSpan.FromSeconds(20));
await a.ReconcileNowAsync();
// Beyond the original expiry, but the reconcile renewed the lease.
time.Advance(TimeSpan.FromSeconds(15));
Assert.That(
async () => await b.AcquireTransactionOwnershipAsync(Session("s2")),
Throws.TypeOf<ServiceResultException>());
Assert.That(a.HoldsLease, Is.True);
}
[Test]
public async Task ApplyChangesReleasesLeaseAndLetsStandbyTakeOverAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
NodeId session = Session("s1");
await a.AcquireTransactionOwnershipAsync(session);
a.Stage(session, NoopOperation());
ServiceResult result = await a.ApplyChangesAsync(session);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(a.HoldsLease, Is.False);
// The lease is released immediately (no need to wait for expiry).
await b.AcquireTransactionOwnershipAsync(Session("s2"));
Assert.That(b.HoldsLease, Is.True);
}
[Test]
public async Task CancelChangesReleasesLeaseAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
NodeId session = Session("s1");
await a.AcquireTransactionOwnershipAsync(session);
a.Stage(session, NoopOperation());
ServiceResult result = a.CancelChanges(session);
// The sync cancel signals the (suppressed) loop; drive the release.
await a.ReconcileNowAsync();
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(a.HoldsLease, Is.False);
await b.AcquireTransactionOwnershipAsync(Session("s2"));
Assert.That(b.HoldsLease, Is.True);
}
[Test]
public async Task ResetReleasesLeaseAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
NodeId session = Session("s1");
await a.AcquireTransactionOwnershipAsync(session);
a.Stage(session, NoopOperation());
a.Reset();
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False);
Assert.That(a.IsTransactionActive, Is.False);
}
[Test]
public async Task SessionCloseReleasesLeaseForOwningSessionAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
NodeId session = Session("s1");
await a.AcquireTransactionOwnershipAsync(session);
a.Stage(session, NoopOperation());
a.CancelForSessionClose(session);
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False);
await b.AcquireTransactionOwnershipAsync(Session("s2"));
Assert.That(b.HoldsLease, Is.True);
}
[Test]
public async Task ConcurrentAcquireLetsExactlyOneReplicaWinAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
Task<bool> ta = TryAcquireAsync(a, Session("s1"));
Task<bool> tb = TryAcquireAsync(b, Session("s2"));
bool[] results = await Task.WhenAll(ta, tb);
int winners = (results[0] ? 1 : 0) + (results[1] ? 1 : 0);
Assert.That(winners, Is.EqualTo(1), "exactly one replica may own the transaction");
}
[Test]
public async Task ForeignOwnerGuardRejectsLocalStageAndValidateAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await using DistributedPushConfigurationTransactionCoordinator b = Create(store, "B", time);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
// The standby observes the foreign lease during a reconcile pass.
await b.ReconcileNowAsync();
Assert.That(
() => b.ValidateSessionCanParticipate(Session("s2")),
Throws.TypeOf<ServiceResultException>());
Assert.That(
() => b.Stage(Session("s2"), NoopOperation()),
Throws.TypeOf<ServiceResultException>());
}
[Test]
public async Task RequireLeadershipRejectsNonLeaderAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
var options = NewOptions("A");
options.RequireLeadership = true;
await using DistributedPushConfigurationTransactionCoordinator a =
new(null, store, options, m_telemetry, time, new FixedLeaderElection(false), startRenewLoop: false);
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await a.AcquireTransactionOwnershipAsync(Session("s1")));
Assert.That(ex.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadTransactionPending));
Assert.That(a.HoldsLease, Is.False);
}
[Test]
public async Task RequireLeadershipAllowsLeaderAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
var options = NewOptions("A");
options.RequireLeadership = true;
await using DistributedPushConfigurationTransactionCoordinator a =
new(null, store, options, m_telemetry, time, new FixedLeaderElection(true), startRenewLoop: false);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
Assert.That(a.HoldsLease, Is.True);
}
[Test]
public async Task InMemoryDevPathRunsFullTransactionLifecycleAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
NodeId session = Session("s1");
await a.AcquireTransactionOwnershipAsync(session);
a.Stage(session, NoopOperation());
Assert.That(a.IsTransactionActive, Is.True);
ServiceResult result = await a.ApplyChangesAsync(session);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(a.IsTransactionActive, Is.False);
Assert.That(a.HoldsLease, Is.False);
}
[Test]
public async Task AcquireAfterDisposeThrowsObjectDisposedAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
await a.DisposeAsync();
Assert.That(
async () => await a.AcquireTransactionOwnershipAsync(Session("s1")),
Throws.TypeOf<ObjectDisposedException>());
}
[Test]
public async Task DelegatesSnapshotToInnerCoordinatorAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a = Create(store, "A", time);
NodeId session = Session("s1");
await a.AcquireTransactionOwnershipAsync(session);
a.Stage(session, NoopOperation());
PushConfigurationTransactionSnapshot snapshot = a.GetSnapshot();
Assert.That(snapshot.IsActive, Is.True);
Assert.That(snapshot.OwnerSessionId, Is.EqualTo(session));
}
[Test]
public async Task NonLeaderRejectionNeverAcquiresLeaseAcrossReconcilesAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a =
Create(store, "A", time, leaderElection: new FixedLeaderElection(false));
// A non-leader is refused and must not reserve ownership.
Assert.ThrowsAsync<ServiceResultException>(
async () => await a.AcquireTransactionOwnershipAsync(Session("s1")));
Assert.That(a.HoldsLease, Is.False);
// Drive reconcile passes inside and beyond the reservation window and
// advance time: a leaked reservation would let the loop acquire the
// lease here. The rejected non-leader must never acquire or hold it.
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False);
time.Advance(s_renewInterval);
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False);
time.Advance(s_leaseDuration);
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False, "a rejected non-leader must never acquire the lease");
(bool found, ByteString lease) = await store.TryGetAsync(LeaseKey());
Assert.That(found && LeaseOwner(lease) == "A", Is.False,
"the shared store must never record the rejected non-leader as the lease owner");
}
[Test]
public async Task ForeignOwnerRejectionNeverSelfAcquiresLeaseAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
// A reservation timeout longer than the lease makes any leaked
// reservation outlive the owner's lease - exactly the window a
// self-promotion bug would exploit once the owner "crashes".
TimeSpan longReservation = s_leaseDuration + s_leaseDuration;
await using DistributedPushConfigurationTransactionCoordinator a =
Create(store, "A", time, reservationTimeout: longReservation);
await using DistributedPushConfigurationTransactionCoordinator b =
Create(store, "B", time, reservationTimeout: longReservation);
await a.AcquireTransactionOwnershipAsync(Session("s1"));
// B is refused because A holds a live lease; it must not reserve.
Assert.ThrowsAsync<ServiceResultException>(
async () => await b.AcquireTransactionOwnershipAsync(Session("s2")));
Assert.That(b.HoldsLease, Is.False);
// While A's lease is live, B's reconcile must back off.
await b.ReconcileNowAsync();
Assert.That(b.HoldsLease, Is.False, "B must back off while A's lease is live");
// A "crashes": its lease lapses while B's would-be leaked reservation
// is still within its (longer) window. B must still never self-acquire.
time.Advance(s_leaseDuration + TimeSpan.FromSeconds(1));
for (int i = 0; i < 3; i++)
{
await b.ReconcileNowAsync();
Assert.That(b.HoldsLease, Is.False,
"a rejected standby must not self-acquire a lapsed lease from a leaked reservation");
time.Advance(s_renewInterval);
}
(bool found, ByteString lease) = await store.TryGetAsync(LeaseKey());
Assert.That(found && LeaseOwner(lease) == "B", Is.False,
"the shared store must never record the rejected standby as the lease owner");
}
[Test]
public async Task CancelledAcquisitionLeavesNoReservationAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
await using DistributedPushConfigurationTransactionCoordinator a =
Create(store, "A", time, reservationTimeout: s_leaseDuration + s_leaseDuration);
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.CatchAsync<OperationCanceledException>(
async () => await a.AcquireTransactionOwnershipAsync(Session("s1"), cts.Token));
Assert.That(a.HoldsLease, Is.False);
// A cancelled acquisition must not leave a reservation that the
// reconcile loop could later use to acquire the lease.
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False);
time.Advance(s_renewInterval);
await a.ReconcileNowAsync();
Assert.That(a.HoldsLease, Is.False, "a cancelled acquisition must never acquire the lease");
(bool found, _) = await store.TryGetAsync(LeaseKey());
Assert.That(found, Is.False, "a cancelled acquisition must not create a lease");
}
[Test]
public async Task RepeatedNonLeaderRejectionsDoNotStarveLeaderAsync()
{
var time = new FakeTimeProvider();
using var store = new InMemorySharedKeyValueStore();
TimeSpan longReservation = s_leaseDuration + s_leaseDuration;
await using DistributedPushConfigurationTransactionCoordinator leader = Create(
store, "A", time, leaderElection: new FixedLeaderElection(true),
reservationTimeout: longReservation);
await using DistributedPushConfigurationTransactionCoordinator standby = Create(
store, "B", time, leaderElection: new FixedLeaderElection(false),
reservationTimeout: longReservation);
// The leader owns an active transaction and renews it as time passes.
NodeId session = Session("s1");
await leader.AcquireTransactionOwnershipAsync(session);
leader.Stage(session, NoopOperation());
Assert.That(leader.HoldsLease, Is.True);
// Hammer the standby with rejected acquisitions while both reconcile
// and time advances. The leader must keep the lease throughout and
// the rejected standby must never acquire it (no starvation).
for (int i = 0; i < 8; i++)
{
Assert.ThrowsAsync<ServiceResultException>(
async () => await standby.AcquireTransactionOwnershipAsync(Session("s-standby")));
Assert.That(standby.HoldsLease, Is.False);
time.Advance(s_renewInterval);
await leader.ReconcileNowAsync();
await standby.ReconcileNowAsync();
Assert.That(leader.HoldsLease, Is.True, "the leader must retain the lease");
Assert.That(standby.HoldsLease, Is.False,
"repeated rejected traffic must not let the standby acquire the lease");
(bool found, ByteString lease) = await store.TryGetAsync(LeaseKey());
Assert.That(found, Is.True);
Assert.That(LeaseOwner(lease), Is.EqualTo("A"),
"the lease must remain owned by the leader across rejected standby traffic");
}
// The leader can still complete its transaction: it was never starved.
ServiceResult result = await leader.ApplyChangesAsync(session);
Assert.That(ServiceResult.IsGood(result), Is.True);
}
private static string LeaseKey()
{
return new DistributedPushConfigurationOptions { ReplicaId = "x" }.TransactionLeaseKey;
}
private static string? LeaseOwner(ByteString raw)
{
if (raw.IsNull)
{
return null;
}
byte[] bytes = raw.ToArray();
if (bytes.Length < 4)
{
return null;
}
int ownerLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(0, 4));
if (ownerLength < 0 || bytes.Length < 4 + ownerLength + 8)
{
return null;
}
return System.Text.Encoding.UTF8.GetString(bytes, 4, ownerLength);
}
private static async Task<bool> TryAcquireAsync(
DistributedPushConfigurationTransactionCoordinator coordinator,
NodeId sessionId)
{
try
{
await coordinator.AcquireTransactionOwnershipAsync(sessionId);
return true;
}
catch (ServiceResultException)
{
return false;
}
}
private DistributedPushConfigurationTransactionCoordinator Create(
InMemorySharedKeyValueStore store,
string replicaId,
FakeTimeProvider time,
ILeaderElection? leaderElection = null,
TimeSpan? reservationTimeout = null)
{
var options = NewOptions(replicaId);
options.RequireLeadership = leaderElection != null;
if (reservationTimeout.HasValue)
{
options.ReservationTimeout = reservationTimeout.Value;
}
return new DistributedPushConfigurationTransactionCoordinator(
null, store, options, m_telemetry, time, leaderElection, startRenewLoop: false);
}
private static DistributedPushConfigurationOptions NewOptions(string replicaId)
{
return new DistributedPushConfigurationOptions
{
ReplicaId = replicaId,
LeaseDuration = s_leaseDuration,
RenewInterval = s_renewInterval
};
}
private static NodeId Session(string id)
{
return new NodeId(id, 1);
}
private static PushConfigurationOperation NoopOperation()
{
return new PushConfigurationOperation
{
CommitAsync = _ => Task.CompletedTask
};
}
private sealed class FixedLeaderElection : ILeaderElection
{
public FixedLeaderElection(bool isLeader)
{
IsLeader = isLeader;
}
public bool IsLeader { get; }
public event Action<bool>? LeadershipChanged;
public ValueTask<bool> TryAcquireOrRenewAsync(CancellationToken ct = default)
{
LeadershipChanged?.Invoke(IsLeader);
return new ValueTask<bool>(IsLeader);
}
public void Start()
{
}
public ValueTask DisposeAsync()
{
return default;
}
}
}
}