Files
UA-.NETStandard/samples/Redundancy/RedundantPubSub/SampleOptions.cs
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## 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.
- [x] 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.
- [x] I built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [x] I have addressed all PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00

239 lines
10 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/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using Opc.Ua.PubSub.Redundancy;
namespace RedundantPubSub
{
/// <summary>
/// Immutable, parsed configuration for the sample, sourced from command-line arguments and
/// environment variables.
/// </summary>
/// <param name="Role">The role to run (publisher, subscriber, or demo).</param>
/// <param name="HaMode">The PubSub redundancy mode (Cold, Warm, or Hot).</param>
/// <param name="Election">The activation-election strategy (leader-election or lease-store).</param>
/// <param name="OwnerId">Stable identifier of this instance within its redundant set.</param>
/// <param name="Endpoint">The PubSub UDP transport endpoint (multicast or unicast).</param>
/// <param name="PublisherId">The PublisherId advertised in network messages.</param>
/// <param name="WriterGroupId">The WriterGroup id used by the writer and reader filter.</param>
/// <param name="DataSetWriterId">The DataSetWriter id used by the writer and reader filter.</param>
/// <param name="IntervalMs">The publishing interval in milliseconds.</param>
/// <param name="RaftId">This instance's Raft member id.</param>
/// <param name="RaftMembers">The total number of Raft members in the set.</param>
/// <param name="RaftBind">The local Raft transport bind address.</param>
/// <param name="RaftPeers">The Raft transport addresses of the peer members.</param>
/// <param name="Insecure">When true, permits a well-known demo record-protection key.</param>
/// <param name="RecordKeyBase64">Optional shared base64 record-protection key.</param>
/// <param name="LeaseDuration">The lease duration used by lease-store election.</param>
/// <param name="DemoFirstActiveDuration">How long the first publisher stays active in the demo.</param>
/// <param name="DemoSecondActiveDuration">How long the promoted publisher runs in the demo.</param>
public sealed record SampleOptions(
SampleRole Role,
PubSubRedundancyMode HaMode,
PubSubRedundancyElection Election,
string OwnerId,
string Endpoint,
ushort PublisherId,
ushort WriterGroupId,
ushort DataSetWriterId,
int IntervalMs,
ulong RaftId,
int RaftMembers,
string RaftBind,
IReadOnlyList<string> RaftPeers,
bool Insecure,
string? RecordKeyBase64,
TimeSpan LeaseDuration,
TimeSpan DemoFirstActiveDuration,
TimeSpan DemoSecondActiveDuration)
{
/// <summary>
/// Parses sample options from command-line arguments (which take precedence) and
/// environment variables, falling back to sample defaults.
/// </summary>
/// <param name="args">The process command-line arguments.</param>
/// <param name="getEnvironment">Callback used to read environment variables.</param>
/// <returns>The parsed <see cref="SampleOptions"/>.</returns>
public static SampleOptions Parse(string[] args, Func<string, string?> getEnvironment)
{
var cli = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int ii = 0; ii < args.Length; ii++)
{
string arg = args[ii];
if (!arg.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
string key = arg[2..].Replace('-', '_');
cli[key] = ii + 1 < args.Length && !args[ii + 1].StartsWith("--", StringComparison.Ordinal)
? args[++ii]
: "true";
}
string Read(string key, string fallback) => cli.TryGetValue(key, out string? value) ? value : getEnvironment(key) ?? fallback;
return new SampleOptions(
ParseRole(Read("ROLE", "demo")),
ParseMode(Read("HA_MODE", "hot")),
ParseElection(Read("HA_ELECTION", "leader-election")),
Read("OWNER_ID", Dns.GetHostName()),
Read("PUBSUB_ENDPOINT", SampleConstants.DefaultEndpoint),
ParseUShort(Read("PUBLISHER_ID", SampleConstants.DefaultPublisherId.ToString(CultureInfo.InvariantCulture))),
ParseUShort(Read("WRITER_GROUP_ID", SampleConstants.DefaultWriterGroupId.ToString(CultureInfo.InvariantCulture))),
ParseUShort(Read("DATA_SET_WRITER_ID", SampleConstants.DefaultDataSetWriterId.ToString(CultureInfo.InvariantCulture))),
int.Parse(Read("PUBLISH_INTERVAL_MS", "1000"), CultureInfo.InvariantCulture),
ulong.Parse(Read("HA_RAFT_ID", "1"), CultureInfo.InvariantCulture),
int.Parse(Read("HA_RAFT_MEMBERS", "1"), CultureInfo.InvariantCulture),
Read("HA_RAFT_BIND", "tcp://0.0.0.0:6560"),
ReadList(Read("HA_RAFT_PEERS", string.Empty)),
bool.TryParse(Read("HA_INSECURE", "false"), out bool insecure) && insecure,
EmptyToNull(Read("HA_RECORD_KEY", string.Empty)),
TimeSpan.FromSeconds(double.Parse(Read("HA_LEASE_SECONDS", "15"), CultureInfo.InvariantCulture)),
TimeSpan.FromSeconds(double.Parse(Read("DEMO_FIRST_SECONDS", "6"), CultureInfo.InvariantCulture)),
TimeSpan.FromSeconds(double.Parse(Read("DEMO_SECOND_SECONDS", "6"), CultureInfo.InvariantCulture)));
}
private static SampleRole ParseRole(string value)
{
return value.Trim().ToLowerInvariant() switch
{
"publisher" => SampleRole.Publisher,
"subscriber" => SampleRole.Subscriber,
"demo" => SampleRole.Demo,
_ => throw new ArgumentException("ROLE must be publisher, subscriber, or demo.", nameof(value))
};
}
private static PubSubRedundancyMode ParseMode(string value)
{
return value.Trim().ToLowerInvariant() switch
{
"hot" => PubSubRedundancyMode.Hot,
"warm" => PubSubRedundancyMode.Warm,
"cold" => PubSubRedundancyMode.Cold,
_ => throw new ArgumentException("HA_MODE must be hot, warm, or cold.", nameof(value))
};
}
private static PubSubRedundancyElection ParseElection(string value)
{
return value.Trim().ToLowerInvariant() switch
{
"leader" or "leader-election" or "leaderelection" => PubSubRedundancyElection.LeaderElection,
"lease" or "lease-store" or "leasestore" => PubSubRedundancyElection.LeaseStore,
_ => throw new ArgumentException("HA_ELECTION must be leader-election or lease-store.", nameof(value))
};
}
private static ushort ParseUShort(string value)
{
return ushort.Parse(value, CultureInfo.InvariantCulture);
}
private static string? EmptyToNull(string value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static string[] ReadList(string value)
{
return string.IsNullOrWhiteSpace(value)
? []
: value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}
/// <summary>
/// The role the sample process runs as.
/// </summary>
public enum SampleRole
{
/// <summary>
/// Runs a high-availability publisher instance.
/// </summary>
Publisher,
/// <summary>
/// Runs a subscriber instance (optionally part of a high-availability reader set).
/// </summary>
Subscriber,
/// <summary>
/// Runs the single-process demo that hosts two publishers and a subscriber.
/// </summary>
Demo
}
/// <summary>
/// Shared constant values used across the sample publisher, subscriber, and demo roles.
/// </summary>
internal static class SampleConstants
{
/// <summary>
/// The published data-set name.
/// </summary>
public const string DataSetName = "HaCounter";
/// <summary>
/// The data-set reader name.
/// </summary>
public const string ReaderName = "Reader 1";
/// <summary>
/// The default PubSub endpoint for multi-process publisher/subscriber roles.
/// </summary>
public const string DefaultEndpoint = "opc.udp://239.0.0.1:4840";
/// <summary>
/// The default PubSub endpoint for the single-process local demo.
/// </summary>
public const string DefaultDemoEndpoint = "opc.udp://127.0.0.1:4840";
/// <summary>
/// The default PublisherId.
/// </summary>
public const ushort DefaultPublisherId = 1;
/// <summary>
/// The default WriterGroup id.
/// </summary>
public const ushort DefaultWriterGroupId = 100;
/// <summary>
/// The default DataSetWriter id.
/// </summary>
public const ushort DefaultDataSetWriterId = 1;
}
}