diff --git a/driver/Aeron.Driver.nuspec b/driver/Aeron.Driver.nuspec index 8783cc94..2d224387 100644 --- a/driver/Aeron.Driver.nuspec +++ b/driver/Aeron.Driver.nuspec @@ -2,7 +2,7 @@ Aeron.Driver - 1.49.0 + 1.51.0 Aeron Driver Adaptive Financial Consulting Ltd. Adaptive Financial Consulting Ltd. diff --git a/driver/aeron-test-loss-generators.jar b/driver/aeron-test-loss-generators.jar new file mode 100644 index 00000000..9fe7a08e Binary files /dev/null and b/driver/aeron-test-loss-generators.jar differ diff --git a/driver/media-driver.jar b/driver/media-driver.jar index 70e43d62..b052acd4 100644 Binary files a/driver/media-driver.jar and b/driver/media-driver.jar differ diff --git a/scripts/update-version.sh b/scripts/update-version.sh index 12ffdc7f..f13148b4 100644 --- a/scripts/update-version.sh +++ b/scripts/update-version.sh @@ -1,13 +1,28 @@ #!/bin/sh -BASEDIR=$(readlink -f $(dirname "$0")) -cd $BASEDIR/.. +BASEDIR=$(cd "$(dirname "$0")" && pwd) +cd "$BASEDIR/.." if [ "$#" -ne 2 ]; then echo "usage: $0 " - exit -1 + exit 1 fi FROM_VERSION=$1 TO_VERSION=$2 -sed -i s/$FROM_VERSION/$TO_VERSION/ driver/Aeron.Driver.nuspec src/Adaptive.Aeron/Adaptive.Aeron.csproj src/Adaptive.Agrona/Adaptive.Agrona.csproj src/Adaptive.Archiver/Adaptive.Archiver.csproj src/Adaptive.Cluster/Adaptive.Cluster.csproj +FROM_MINOR=$(echo "$FROM_VERSION" | cut -d. -f2) +TO_MINOR=$(echo "$TO_VERSION" | cut -d. -f2) + +# GNU sed uses `-i ''` differently than BSD sed; use a portable form via -i.bak then remove backups. +sed -i.bak "s/$FROM_VERSION/$TO_VERSION/g" \ + driver/Aeron.Driver.nuspec \ + src/Adaptive.Aeron/Adaptive.Aeron.csproj \ + src/Adaptive.Agrona/Adaptive.Agrona.csproj \ + src/Adaptive.Archiver/Adaptive.Archiver.csproj \ + src/Adaptive.Cluster/Adaptive.Cluster.csproj \ + src/Adaptive.Aeron/AeronVersion.cs + +sed -i.bak "s/MINOR_VERSION = $FROM_MINOR/MINOR_VERSION = $TO_MINOR/g" \ + src/Adaptive.Aeron/AeronVersion.cs + +find driver src -name '*.bak' -delete diff --git a/src/Adaptive.Aeron.Tests/SubscriptionTest.cs b/src/Adaptive.Aeron.Tests/SubscriptionTest.cs index b0732590..95d581b6 100644 --- a/src/Adaptive.Aeron.Tests/SubscriptionTest.cs +++ b/src/Adaptive.Aeron.Tests/SubscriptionTest.cs @@ -16,6 +16,7 @@ using Adaptive.Aeron.LogBuffer; using Adaptive.Aeron.Protocol; +using Adaptive.Aeron.Status; using Adaptive.Agrona.Concurrent; using Adaptive.Agrona.Concurrent.Status; using FakeItEasy; @@ -155,5 +156,51 @@ public void ShouldReadDataFromMultipleSources() Assert.AreEqual(2, _subscription.Poll(_fragmentHandler, FragmentCountLimit)); } + + // Cases where TryResolveChannelEndpointPort short-circuits without reading from the CountersReader. + // These cover the 1.51.0 caching/MDS-manual logic added in Subscription.cs. + // Tests that exercise LocalSocketAddressStatus lookups (active/errored bind addresses) are not + // ported yet — they require a .NET-side Allocate helper that doesn't exist today. + [TestCase("aeron:ipc")] + [TestCase("aeron:udp?control-mode=response|control=localhost:5555")] + [TestCase("aeron:udp?endpoint=localhost:8888")] + [TestCase("aeron:udp?control-mode=manual|endpoint=localhost:0")] + [TestCase("aeron:udp?control-mode=dynamic|control=localhost:7777")] + public void TryResolveChannelEndpointPortReturnsOriginalUriIfEndpointDoesNotNeedResolving(string channel) + { + const int channelStatusId = 777; + _subscription = new Subscription( + _conductor, + channel, + StreamId1, + SubscriptionCorrelationId, + _availableImageHandler, + _unavailableImageHandler + ) + { + ChannelStatusId = channelStatusId + }; + A.CallTo(() => _conductor.ChannelStatus(channelStatusId)).Returns(ChannelEndpointStatus.ERRORED); + + Assert.AreSame(channel, _subscription.TryResolveChannelEndpointPort()); + // Subsequent calls return the same cached result. + Assert.AreSame(channel, _subscription.TryResolveChannelEndpointPort()); + } + + [Test] + public void ShouldAcceptBrokenChannelUriAtCreationTime() + { + const string channel = "broken uri"; + _subscription = new Subscription( + _conductor, + channel, + StreamId1, + SubscriptionCorrelationId, + _availableImageHandler, + _unavailableImageHandler + ); + + Assert.AreEqual(channel, _subscription.Channel); + } } } diff --git a/src/Adaptive.Aeron.sln b/src/Adaptive.Aeron.sln index 20d73426..b883a884 100644 --- a/src/Adaptive.Aeron.sln +++ b/src/Adaptive.Aeron.sln @@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unsealer.Fody", "Weavers\Un EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Adaptive.Aeron.Analyzers", "Adaptive.Aeron.Analyzers\Adaptive.Aeron.Analyzers.csproj", "{926CB849-94C6-4F3E-8B4F-1DB5157A8782}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Adaptive.Archiver.IntegrationTests", "Adaptive.Archiver.IntegrationTests\Adaptive.Archiver.IntegrationTests.csproj", "{93095200-8C85-439B-BB5E-D3440BBBF334}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -457,6 +459,22 @@ Global {926CB849-94C6-4F3E-8B4F-1DB5157A8782}.Release|x64.Build.0 = Release|Any CPU {926CB849-94C6-4F3E-8B4F-1DB5157A8782}.Release|x86.ActiveCfg = Release|Any CPU {926CB849-94C6-4F3E-8B4F-1DB5157A8782}.Release|x86.Build.0 = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|arm64.ActiveCfg = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|arm64.Build.0 = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|x64.ActiveCfg = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|x64.Build.0 = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|x86.ActiveCfg = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Debug|x86.Build.0 = Debug|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|Any CPU.Build.0 = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|arm64.ActiveCfg = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|arm64.Build.0 = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|x64.ActiveCfg = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|x64.Build.0 = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|x86.ActiveCfg = Release|Any CPU + {93095200-8C85-439B-BB5E-D3440BBBF334}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Adaptive.Aeron/Adaptive.Aeron.csproj b/src/Adaptive.Aeron/Adaptive.Aeron.csproj index b5d49aa9..05f876b9 100644 --- a/src/Adaptive.Aeron/Adaptive.Aeron.csproj +++ b/src/Adaptive.Aeron/Adaptive.Aeron.csproj @@ -2,7 +2,7 @@ netstandard2.0 Aeron.Client - 1.49.0 + 1.51.0 Adaptive Financial Consulting Ltd. Adaptive Financial Consulting Ltd. Aeron Client @@ -49,4 +49,43 @@ + + + + + + + + + + + <_AeronGitSha Condition="'$(_GitShaExitCode)' == '0' and '$(_GitStatusRaw)' == ''">$(_GitShaRaw) + <_AeronGitSha Condition="'$(_GitShaExitCode)' == '0' and '$(_GitStatusRaw)' != ''">$(_GitShaRaw)+guilty + <_AeronGitSha Condition="'$(_GitShaExitCode)' != '0'">unknown + <_GeneratedGitShaFile>$(IntermediateOutputPath)AeronVersion.GitSha.g.cs + + + + + + + + \ No newline at end of file diff --git a/src/Adaptive.Aeron/Aeron.cs b/src/Adaptive.Aeron/Aeron.cs index 6d23f791..01e21634 100644 --- a/src/Adaptive.Aeron/Aeron.cs +++ b/src/Adaptive.Aeron/Aeron.cs @@ -1121,7 +1121,8 @@ static Context() baseDirName = Path.Combine(Path.GetTempPath(), "aeron"); } - AERON_DIR_PROP_DEFAULT = baseDirName + "-" + Environment.UserName; + var userName = Environment.UserName; + AERON_DIR_PROP_DEFAULT = baseDirName + "-" + (string.IsNullOrEmpty(userName) ? "default" : userName); } /// @@ -1487,7 +1488,8 @@ static Context() /// public static bool ShouldPrintConfigurationOnStart() { - return "true".Equals(Config.GetProperty(PRINT_CONFIGURATION_ON_START_PROP_NAME)); + // Use bool.TryParse for case-insensitive parsing to match Java's parseBoolean. + return bool.TryParse(Config.GetProperty(PRINT_CONFIGURATION_ON_START_PROP_NAME), out var b) && b; } /// @@ -1502,7 +1504,7 @@ public static TextWriter FallbackLogger() case "stdout": return Console.Out; - case "noop": + case "no_op": return new StreamWriter(Stream.Null); case "stderr": @@ -1537,7 +1539,11 @@ public Context ConcludeAeronDirectory() { if (null == _aeronDirectory) { - _aeronDirectory = new DirectoryInfo(_aeronDirectoryName); + // Match Java's CommonContext.concludeAeronDirectory() which calls + // new File(name).getCanonicalFile() — normalises ./.. and resolves to the + // absolute path. Two processes referencing the same dir via different + // relative paths must agree on the canonical form to share the CnC file. + _aeronDirectory = new DirectoryInfo(Path.GetFullPath(_aeronDirectoryName)); } return this; @@ -2569,7 +2575,6 @@ public void Dispose() _cncMetaDataBuffer?.Dispose(); _countersMetaDataBuffer?.Dispose(); _countersValuesBuffer?.Dispose(); - _cncByteBuffer?.Dispose(); } /// @@ -2672,7 +2677,7 @@ private void ConnectToDriver() { if (clock.Time() > deadlineMs) { - throw new DriverTimeoutException("no driver heartbeat detected."); + throw new DriverTimeoutException("no driver heartbeat detected"); } Sleep(Configuration.AWAITING_IDLE_SLEEP_MS); @@ -2683,7 +2688,7 @@ private void ConnectToDriver() { if (timeMs > deadlineMs) { - throw new DriverTimeoutException("no driver heartbeat detected."); + throw new DriverTimeoutException("no driver heartbeat detected"); } IoUtil.Unmap(_cncByteBuffer); @@ -2769,7 +2774,7 @@ public MappedByteBuffer MapExistingCncFile(Action logProgress) { if (null != logProgress) { - logProgress("INFO: Aeron CnC file " + cncFile + "exists"); + logProgress("INFO: Aeron CnC file exists: " + cncFile); } return IoUtil.MapExistingFile(cncFile, CncFileDescriptor.CNC_FILE); @@ -2853,7 +2858,7 @@ MappedByteBuffer cncByteBuffer { if (UnixTimeConverter.CurrentUnixTimeMillis() > (startTimeMs + driverTimeoutMs)) { - throw new DriverTimeoutException("CnC file is created but not initialised."); + throw new DriverTimeoutException("CnC file is created but not initialised"); } Sleep(1); @@ -2866,7 +2871,7 @@ MappedByteBuffer cncByteBuffer ); long timestampMs = toDriverBuffer.ConsumerHeartbeatTime(); - long nowMs = DateTime.Now.ToFileTimeUtc(); + long nowMs = UnixTimeConverter.CurrentUnixTimeMillis(); long timestampAgeMs = nowMs - timestampMs; logger("INFO: Aeron toDriver consumer heartbeat age is (ms):" + timestampAgeMs); diff --git a/src/Adaptive.Aeron/AeronCounters.cs b/src/Adaptive.Aeron/AeronCounters.cs index 8e3dce3c..04b8d6c6 100644 --- a/src/Adaptive.Aeron/AeronCounters.cs +++ b/src/Adaptive.Aeron/AeronCounters.cs @@ -264,6 +264,19 @@ public static class AeronCounters /// Since 1.49.0 public const int SYSTEM_COUNTER_ID_CONTROL_PROTOCOL_VERSION = 43; + /// + /// Counter id for status messages that are rejected while being outside the send window, i.e. being behind or + /// ahead of the snd-pos by more than one term. + /// + /// Since 1.51.0 + public const int SYSTEM_COUNTER_ID_STATUS_MESSAGES_REJECTED = 44; + + /// + /// Counter id for failed offers to the async executor proxy. + /// + /// Since 1.51.0 + public const int SYSTEM_COUNTER_ID_ASYNC_EXECUTOR_PROXY_FAILS = 45; + // Client/driver counters /// @@ -378,6 +391,188 @@ public static class AeronCounters /// public const int DRIVER_RECEIVER_NAKS_SENT_TYPE_ID = 20; + /// + /// Counter for each bootstrap neighbors used for driver name resolution. + /// + /// Since 1.51.0 + public const int NAME_RESOLVER_BOOTSTRAP_NEIGHBOR_COUNTER_TYPE_ID = 21; + + // EF_VI counters + /// EF_VI_PORT_INFO_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_PORT_INFO_TYPE_ID = 50; + + /// EF_VI_TRANSPORT_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_TRANSPORT_TYPE_ID = 51; + + /// EF_VI_TX_NOBUFS_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_TX_NOBUFS_TYPE_ID = 52; + + /// EF_VI_TX_EAGAIN_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_TX_EAGAIN_TYPE_ID = 53; + + /// EF_VI_TX_ERROR_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_TX_ERROR_TYPE_ID = 54; + + /// EF_VI_RX_DISCARD_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_RX_DISCARD_TYPE_ID = 55; + + /// EF_VI_RX_INVALID_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_RX_INVALID_TYPE_ID = 56; + + /// EF_VI_RX_PKTS_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_RX_PKTS_TYPE_ID = 57; + + /// EF_VI_RX_BYTES_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_RX_BYTES_TYPE_ID = 58; + + /// EF_VI_TX_PKTS_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_TX_PKTS_TYPE_ID = 59; + + /// EF_VI_TX_BYTES_TYPE_ID. + /// Since 1.51.0 + public const int EF_VI_TX_BYTES_TYPE_ID = 60; + + // VMA counters + /// VMA_TRANSPORTS_TYPE_ID. + /// Since 1.51.0 + public const int VMA_TRANSPORTS_TYPE_ID = 61; + + /// VMA_RX_ZERO_COPY_BYTES_TYPE_ID. + /// Since 1.51.0 + public const int VMA_RX_ZERO_COPY_BYTES_TYPE_ID = 62; + + /// VMA_RX_DATA_COPY_BYTES_TYPE_ID. + /// Since 1.51.0 + public const int VMA_RX_DATA_COPY_BYTES_TYPE_ID = 63; + + // ATS counters + /// ATS_TRANSPORTS_TYPE_ID. + /// Since 1.51.0 + public const int ATS_TRANSPORTS_TYPE_ID = 65; + + /// ATS_DISCARDS_NON_ATS_TYPE_ID. + /// Since 1.51.0 + public const int ATS_DISCARDS_NON_ATS_TYPE_ID = 66; + + /// ATS_BYTES_ENCRYPTED_TYPE_ID. + /// Since 1.51.0 + public const int ATS_BYTES_ENCRYPTED_TYPE_ID = 67; + + /// ATS_BYTES_DECRYPTED_TYPE_ID. + /// Since 1.51.0 + public const int ATS_BYTES_DECRYPTED_TYPE_ID = 68; + + /// ATS_AEAD_ERRORS_TYPE_ID. + /// Since 1.51.0 + public const int ATS_AEAD_ERRORS_TYPE_ID = 69; + + /// ATS_RSA_KEY_UNKNOWN_TYPE_ID. + /// Since 1.51.0 + public const int ATS_RSA_KEY_UNKNOWN_TYPE_ID = 70; + + /// ATS_EC_KEY_SIG_ERRORS_TYPE_ID. + /// Since 1.51.0 + public const int ATS_EC_KEY_SIG_ERRORS_TYPE_ID = 71; + + /// ATS_UNICAST_RE_KEYINGS_TYPE_ID. + /// Since 1.51.0 + public const int ATS_UNICAST_RE_KEYINGS_TYPE_ID = 72; + + /// ATS_UNICAST_RE_KEYING_RSA_KEY_MISMATCH_TYPE_ID. + /// Since 1.51.0 + public const int ATS_UNICAST_RE_KEYING_RSA_KEY_MISMATCH_TYPE_ID = 73; + + /// ATS_DROPPED_SM_TYPE_ID. + /// Since 1.51.0 + public const int ATS_DROPPED_SM_TYPE_ID = 74; + + // DPDK counters + /// DPDK_PORT_INFO_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_PORT_INFO_TYPE_ID = 75; + + /// DPDK_TRANSPORT_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_TRANSPORT_TYPE_ID = 76; + + /// DPDK_NOBUFS_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_NOBUFS_TYPE_ID = 77; + + /// DPDK_TX_EAGAIN_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_TX_EAGAIN_TYPE_ID = 78; + + /// DPDK_ERROR_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_ERROR_TYPE_ID = 79; + + /// DPDK_PKTS_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_PKTS_TYPE_ID = 82; + + /// DPDK_BYTES_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_BYTES_TYPE_ID = 83; + + /// DPDK_MISSED_PACKETS_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_MISSED_PACKETS_TYPE_ID = 84; + + /// DPDK_ARP_MISS_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_ARP_MISS_TYPE_ID = 85; + + /// DPDK_RX_SENDER_DISCARD_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_RX_SENDER_DISCARD_TYPE_ID = 86; + + /// DPDK_POLLER_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_POLLER_TYPE_ID = 87; + + /// DPDK_QUEUE_DROP_COUNT_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_QUEUE_DROP_COUNT_TYPE_ID = 88; + + /// DPDK_CHECKSUM_FAILURE_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_CHECKSUM_FAILURE_TYPE_ID = 89; + + /// DPDK_FRAGMENTED_PACKETS_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_FRAGMENTED_PACKETS_TYPE_ID = 90; + + /// DPDK_MEMPOOL_AVAILABLE_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_MEMPOOL_AVAILABLE_TYPE_ID = 91; + + /// DPDK_EXTENDED_STATS_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_EXTENDED_STATS_TYPE_ID = 92; + + /// DPDK_RX_UNSUPPORTED_ETHERNET_TYPE_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_RX_UNSUPPORTED_ETHERNET_TYPE_TYPE_ID = 93; + + /// DPDK_RX_UNSUPPORTED_PROTOCOL_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_RX_UNSUPPORTED_PROTOCOL_TYPE_ID = 94; + + /// DPDK_RX_RECEIVER_DISCARD_TYPE_ID. + /// Since 1.51.0 + public const int DPDK_RX_RECEIVER_DISCARD_TYPE_ID = 95; + // Archive counters /// /// The position a recording has reached when being archived. @@ -460,6 +655,34 @@ public static class AeronCounters /// Since 1.49.0 public const int ARCHIVE_CONTROL_SESSION_TYPE_ID = 113; + /// + /// The type id of the used to track the current state of a + /// PersistentSubscription. + /// + /// Since 1.51.0 + public const int PERSISTENT_SUBSCRIPTION_STATE_TYPE_ID = 114; + + /// + /// The type id of the used to track the join difference of a + /// PersistentSubscription. + /// + /// Since 1.51.0 + public const int PERSISTENT_SUBSCRIPTION_JOIN_DIFFERENCE_TYPE_ID = 115; + + /// + /// The type id of the used to count how many times a + /// PersistentSubscription has left the live channel. + /// + /// Since 1.51.0 + public const int PERSISTENT_SUBSCRIPTION_LIVE_LEFT_COUNT_TYPE_ID = 116; + + /// + /// The type id of the used to count how many times a + /// PersistentSubscription has joined the live channel. + /// + /// Since 1.51.0 + public const int PERSISTENT_SUBSCRIPTION_LIVE_JOINED_COUNT_TYPE_ID = 117; + // Cluster counters /// @@ -590,6 +813,8 @@ public static class AeronCounters /// /// Transition module control toggle type id. /// + /// Deprecated in 1.51.0 — commented out upstream by aeron-io/aeron#1891. Retained for binary + /// compatibility but no longer used by the driver. public const int TRANSITION_MODULE_CONTROL_TOGGLE_TYPE_ID = 225; /// @@ -682,6 +907,22 @@ public static class AeronCounters /// Since 1.49.0 public const int CLUSTER_SESSION_TYPE_ID = 241; + /// SELECTOR_CLIENTS_COUNTER_TYPE_ID. + /// Since 1.51.0 + public const int SELECTOR_CLIENTS_COUNTER_TYPE_ID = 300; + + /// SELECTOR_SUBSCRIPTIONS_COUNTER_TYPE_ID. + /// Since 1.51.0 + public const int SELECTOR_SUBSCRIPTIONS_COUNTER_TYPE_ID = 301; + + /// SELECTOR_MAX_CYCLE_TIME_TYPE_ID. + /// Since 1.51.0 + public const int SELECTOR_MAX_CYCLE_TIME_TYPE_ID = 302; + + /// SELECTOR_CYCLE_TIME_THRESHOLD_EXCEEDED_TYPE_ID. + /// Since 1.51.0 + public const int SELECTOR_CYCLE_TIME_THRESHOLD_EXCEEDED_TYPE_ID = 303; + // =================== // Sequencer Counters. // =================== diff --git a/src/Adaptive.Aeron/AeronVersion.cs b/src/Adaptive.Aeron/AeronVersion.cs index 528a3e8c..ad6375f1 100644 --- a/src/Adaptive.Aeron/AeronVersion.cs +++ b/src/Adaptive.Aeron/AeronVersion.cs @@ -21,11 +21,17 @@ namespace Adaptive.Aeron "S1118:Utility classes should not have public constructors", Justification = "Public ctor in shipped API surface; marking static would break consumers." )] - public class AeronVersion + public partial class AeronVersion { - public const string VERSION = "1.49.0"; + public const string VERSION = "1.51.0"; public const int MAJOR_VERSION = 1; - public const int MINOR_VERSION = 49; + public const int MINOR_VERSION = 51; public const int PATCH_VERSION = 0; + + // GIT_SHA constant is defined in the generated partial AeronVersion.GitSha.g.cs. + // The build target in Adaptive.Aeron.csproj writes the current short SHA on every + // build, appending "+guilty" when the working tree has uncommitted changes. + // When git is not available (e.g. building from a packaged source drop), the + // value falls back to "unknown". } } diff --git a/src/Adaptive.Aeron/BufferBuilder.cs b/src/Adaptive.Aeron/BufferBuilder.cs index ceaf3ac6..ed5f6375 100644 --- a/src/Adaptive.Aeron/BufferBuilder.cs +++ b/src/Adaptive.Aeron/BufferBuilder.cs @@ -40,6 +40,7 @@ public sealed class BufferBuilder private int _limit; private int _nextTermOffset = Aeron.NULL_VALUE; + private int _firstFrameLength; private readonly UnsafeBuffer _buffer = new UnsafeBuffer(); readonly UnsafeBuffer _headerBuffer = new UnsafeBuffer(); readonly Header _completeHeader = new Header(0, 0); @@ -192,6 +193,8 @@ public BufferBuilder CaptureHeader(Header header) _completeHeader.Offset = 0; _completeHeader.Buffer = _headerBuffer; + _firstFrameLength = header.FrameLength; + _headerBuffer.PutBytes(0, header.Buffer, header.Offset, HEADER_LENGTH); return this; } @@ -204,15 +207,14 @@ public BufferBuilder CaptureHeader(Header header) /// complete message header. public Header CompleteHeader(Header header) { - int firstFrameLength = _headerBuffer.GetInt(FRAME_LENGTH_FIELD_OFFSET, ByteOrder.LittleEndian); - int fragmentedFrameLength = ComputeFragmentedFrameLength(_limit, firstFrameLength - HEADER_LENGTH); + // compute the `fragmented frame length` of the complete message + int fragmentedFrameLength = ComputeFragmentedFrameLength(_limit, _firstFrameLength - HEADER_LENGTH); _completeHeader.Context = header.Context; _completeHeader.FragmentedFrameLength = fragmentedFrameLength; _headerBuffer.PutInt(FRAME_LENGTH_FIELD_OFFSET, HEADER_LENGTH + _limit, ByteOrder.LittleEndian); // compute complete flags _headerBuffer.PutByte(FLAGS_OFFSET, (byte)(_headerBuffer.GetByte(FLAGS_OFFSET) | header.Flags)); - // compute the `fragmented frame length` of the complete message return _completeHeader; } diff --git a/src/Adaptive.Aeron/ChannelUri.cs b/src/Adaptive.Aeron/ChannelUri.cs index 4d96c4c5..5da891d9 100644 --- a/src/Adaptive.Aeron/ChannelUri.cs +++ b/src/Adaptive.Aeron/ChannelUri.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; using Adaptive.Aeron.LogBuffer; using Adaptive.Agrona.Collections; @@ -528,7 +529,12 @@ public static bool IsTagged(string paramValue) /// public static long GetTag(string paramValue) { - return IsTagged(paramValue) ? long.Parse(paramValue.Substring(4, paramValue.Length - 4)) : INVALID_TAG; + if (!IsTagged(paramValue)) + { + return INVALID_TAG; + } + var tagText = paramValue.Substring(4, paramValue.Length - 4); + return long.Parse(tagText, NumberStyles.Integer, CultureInfo.InvariantCulture); } /// diff --git a/src/Adaptive.Aeron/ChannelUriStringBuilder.cs b/src/Adaptive.Aeron/ChannelUriStringBuilder.cs index bf2693cb..6b8b5a56 100644 --- a/src/Adaptive.Aeron/ChannelUriStringBuilder.cs +++ b/src/Adaptive.Aeron/ChannelUriStringBuilder.cs @@ -15,6 +15,7 @@ */ using System; +using System.Globalization; using System.Text; using Adaptive.Aeron.LogBuffer; using Adaptive.Agrona; @@ -1135,7 +1136,7 @@ public ChannelUriStringBuilder Tether(ChannelUri channelUri) /// public ChannelUriStringBuilder Group(bool? group) { - this._group = group; + _group = group; return this; } @@ -1297,7 +1298,7 @@ public string Alias() /// public ChannelUriStringBuilder CongestionControl(string congestionControl) { - this._cc = congestionControl; + _cc = congestionControl; return this; } @@ -1330,7 +1331,7 @@ public string CongestionControl() /// public ChannelUriStringBuilder FlowControl(string flowControl) { - this._fc = flowControl; + _fc = flowControl; return this; } @@ -1426,7 +1427,7 @@ public string FlowControl() /// public ChannelUriStringBuilder GroupTag(long? groupTag) { - this._groupTag = groupTag; + _groupTag = groupTag; return this; } @@ -1475,7 +1476,7 @@ public ChannelUriStringBuilder GroupTag(ChannelUri channelUri) /// public ChannelUriStringBuilder Rejoin(bool? rejoin) { - this._rejoin = rejoin; + _rejoin = rejoin; return this; } @@ -1518,7 +1519,7 @@ public ChannelUriStringBuilder Rejoin(ChannelUri channelUri) /// public ChannelUriStringBuilder SpiesSimulateConnection(bool? spiesSimulateConnection) { - this._ssc = spiesSimulateConnection; + _ssc = spiesSimulateConnection; return this; } @@ -1592,7 +1593,7 @@ public ChannelUriStringBuilder InitialPosition(long position, int initialTermId, /// public ChannelUriStringBuilder SocketSndbufLength(int? socketSndbufLength) { - _socketSndbufLength = socketSndbufLength; + _socketSndbufLength = RequireNonNegative(socketSndbufLength, SOCKET_SNDBUF_PARAM_NAME); return this; } @@ -1640,7 +1641,7 @@ public ChannelUriStringBuilder SocketSndbufLength(ChannelUri channelUri) /// public ChannelUriStringBuilder SocketRcvbufLength(int? socketRcvbufLength) { - _socketRcvbufLength = socketRcvbufLength; + _socketRcvbufLength = RequireNonNegative(socketRcvbufLength, SOCKET_RCVBUF_PARAM_NAME); return this; } @@ -1656,7 +1657,7 @@ public ChannelUriStringBuilder SocketRcvbufLength(ChannelUri channelUri) string valueStr = channelUri.Get(SOCKET_RCVBUF_PARAM_NAME); if (null == valueStr) { - this._socketRcvbufLength = null; + _socketRcvbufLength = null; return this; } else @@ -1689,7 +1690,7 @@ public ChannelUriStringBuilder SocketRcvbufLength(ChannelUri channelUri) /// public ChannelUriStringBuilder ReceiverWindowLength(int? receiverWindowLength) { - this._receiverWindowLength = receiverWindowLength; + _receiverWindowLength = RequireNonNegative(receiverWindowLength, RECEIVER_WINDOW_LENGTH_PARAM_NAME); return this; } @@ -1706,7 +1707,7 @@ public ChannelUriStringBuilder ReceiverWindowLength(ChannelUri channelUri) string valueStr = channelUri.Get(RECEIVER_WINDOW_LENGTH_PARAM_NAME); if (null == valueStr) { - this._receiverWindowLength = null; + _receiverWindowLength = null; return this; } else @@ -1758,7 +1759,7 @@ public ChannelUriStringBuilder MediaReceiveTimestampOffset(string timestampOffse { try { - int.Parse(timestampOffset); + int.Parse(timestampOffset, NumberStyles.Integer, CultureInfo.InvariantCulture); } catch (FormatException) { @@ -1771,7 +1772,7 @@ public ChannelUriStringBuilder MediaReceiveTimestampOffset(string timestampOffse } } - this._mediaReceiveTimestampOffset = timestampOffset; + _mediaReceiveTimestampOffset = timestampOffset; return this; } @@ -1814,7 +1815,7 @@ public ChannelUriStringBuilder ChannelReceiveTimestampOffset(string timestampOff { try { - int.Parse(timestampOffset); + int.Parse(timestampOffset, NumberStyles.Integer, CultureInfo.InvariantCulture); } catch (FormatException) { @@ -1827,7 +1828,7 @@ public ChannelUriStringBuilder ChannelReceiveTimestampOffset(string timestampOff } } - this._channelReceiveTimestampOffset = timestampOffset; + _channelReceiveTimestampOffset = timestampOffset; return this; } @@ -1859,7 +1860,7 @@ public ChannelUriStringBuilder ChannelSendTimestampOffset(string timestampOffset { try { - int.Parse(timestampOffset); + int.Parse(timestampOffset, NumberStyles.Integer, CultureInfo.InvariantCulture); } catch (FormatException) { @@ -1908,7 +1909,7 @@ public string ChannelSendTimestampOffset() /// public ChannelUriStringBuilder ResponseEndpoint(string responseEndpoint) { - this._responseEndpoint = responseEndpoint; + _responseEndpoint = responseEndpoint; return this; } @@ -1931,7 +1932,7 @@ public ChannelUriStringBuilder ResponseEndpoint(ChannelUri channelUri) /// public string ResponseEndpoint() { - return this._responseEndpoint; + return _responseEndpoint; } /// @@ -1973,7 +1974,7 @@ public ChannelUriStringBuilder ResponseCorrelationId(string responseCorrelationI { try { - if (long.Parse(responseCorrelationId) < -1) + if (long.Parse(responseCorrelationId, NumberStyles.Integer, CultureInfo.InvariantCulture) < -1) { throw new FormatException("responseCorrelationId must be positive"); } @@ -2102,6 +2103,7 @@ public ChannelUriStringBuilder UntetheredWindowLimitTimeout(string timeout) /// public ChannelUriStringBuilder UntetheredWindowLimitTimeoutNs(long? timeout) { + timeout = RequireNonNegative(timeout, UNTETHERED_WINDOW_LIMIT_TIMEOUT_PARAM_NAME); _untetheredWindowLimitTimeoutNs = timeout; return this; } @@ -2158,7 +2160,7 @@ public ChannelUriStringBuilder UntetheredLingerTimeout(string timeout) /// public ChannelUriStringBuilder UntetheredLingerTimeoutNs(long? timeout) { - _untetheredLingerTimeoutNs = timeout; + _untetheredLingerTimeoutNs = RequireNonNegative(timeout, UNTETHERED_LINGER_TIMEOUT_PARAM_NAME); return this; } @@ -2214,7 +2216,7 @@ public ChannelUriStringBuilder UntetheredRestingTimeout(string timeout) /// public ChannelUriStringBuilder UntetheredRestingTimeoutNs(long? timeout) { - _untetheredRestingTimeoutNs = timeout; + _untetheredRestingTimeoutNs = RequireNonNegative(timeout, UNTETHERED_RESTING_TIMEOUT_PARAM_NAME); return this; } @@ -2250,7 +2252,7 @@ public ChannelUriStringBuilder UntetheredRestingTimeout(ChannelUri channelUri) /// public ChannelUriStringBuilder MaxResend(int? maxResend) { - this._maxResend = maxResend; + _maxResend = RequireNonNegative(maxResend, MAX_RESEND_PARAM_NAME); return this; } @@ -2265,14 +2267,14 @@ public ChannelUriStringBuilder MaxResend(ChannelUri channelUri) string valueStr = channelUri.Get(MAX_RESEND_PARAM_NAME); if (null == valueStr) { - this._maxResend = null; + _maxResend = null; return this; } else { try { - return MaxResend(int.Parse(valueStr)); + return MaxResend(int.Parse(valueStr, NumberStyles.Integer, CultureInfo.InvariantCulture)); } catch (System.FormatException ex) { @@ -2307,7 +2309,7 @@ public ChannelUriStringBuilder MaxResend(ChannelUri channelUri) /// this for a fluent API. public ChannelUriStringBuilder StreamId(int? streamId) { - this._streamId = streamId; + _streamId = streamId; return this; } @@ -2321,14 +2323,14 @@ public ChannelUriStringBuilder StreamId(ChannelUri channelUri) string valueStr = channelUri.Get(STREAM_ID_PARAM_NAME); if (null == valueStr) { - this._streamId = null; + _streamId = null; return this; } else { try { - return StreamId(int.Parse(valueStr)); + return StreamId(int.Parse(valueStr, NumberStyles.Integer, CultureInfo.InvariantCulture)); } catch (System.FormatException ex) { @@ -2345,7 +2347,8 @@ public ChannelUriStringBuilder StreamId(ChannelUri channelUri) /// public ChannelUriStringBuilder PublicationWindowLength(int? publicationWindowLength) { - this._publicationWindowLength = publicationWindowLength; + _publicationWindowLength = + RequireNonNegative(publicationWindowLength, PUBLICATION_WINDOW_LENGTH_PARAM_NAME); return this; } @@ -2361,7 +2364,7 @@ public ChannelUriStringBuilder PublicationWindowLength(ChannelUri channelUri) string valueStr = channelUri.Get(PUBLICATION_WINDOW_LENGTH_PARAM_NAME); if (null == valueStr) { - this._publicationWindowLength = null; + _publicationWindowLength = null; return this; } else @@ -2498,5 +2501,23 @@ private static string PrefixTag(bool isTagged, long? value) { return isTagged ? TAG_PREFIX + value : value.ToString(); } + + private static long? RequireNonNegative(long? value, string name) + { + if (null != value && value.Value < 0) + { + throw new ArgumentException("`" + name + "` value cannot be negative: " + value); + } + return value; + } + + private static int? RequireNonNegative(int? value, string name) + { + if (null != value && value.Value < 0) + { + throw new ArgumentException("`" + name + "` value cannot be negative: " + value); + } + return value; + } } } diff --git a/src/Adaptive.Aeron/ClientConductor.cs b/src/Adaptive.Aeron/ClientConductor.cs index 5454b883..775e961d 100644 --- a/src/Adaptive.Aeron/ClientConductor.cs +++ b/src/Adaptive.Aeron/ClientConductor.cs @@ -255,7 +255,7 @@ public int DoWork() public string RoleName() { - return "aeron-client-conductor"; + return "aeron-client"; } internal bool IsClosed() @@ -479,7 +479,9 @@ internal void OnUnavailableImage(long correlationId, long subscriptionRegistrati internal void OnNewCounter(long correlationId, int counterId) { - _resourceByRegIdMap.Put(correlationId, new Counter(correlationId, this, _counterValuesBuffer, counterId)); + _resourceByRegIdMap.Put( + correlationId, + new Counter(correlationId, correlationId, true, this, _counterValuesBuffer, counterId)); OnAvailableCounter(correlationId, counterId); } @@ -1674,7 +1676,7 @@ var keyValuePair in _closeHandlersByIdMap.KeyValuePairs.Where(kvp => kvp.Value = } } - internal void ReleaseCounter(Counter counter) + internal void RemoveCounter(Counter counter) { _clientLock.Lock(); try @@ -1686,10 +1688,10 @@ internal void ReleaseCounter(Counter counter) EnsureNotReentrant(); - long registrationId = counter.RegistrationId; - if (counter == _resourceByRegIdMap.Remove(registrationId)) + long correlationId = counter.CorrelationId; + if (counter == _resourceByRegIdMap.Remove(correlationId) && counter.ClientOwned()) { - _asyncCommandIdSet.Add(_driverProxy.RemoveCounter(registrationId)); + _asyncCommandIdSet.Add(_driverProxy.RemoveCounter(correlationId)); } } finally @@ -1732,9 +1734,17 @@ internal long ChannelStatus(int channelStatusId) internal void CloseImages(Image[] images, UnavailableImageHandler unavailableImageHandler, long lingerNs) { + // Two-pass to match upstream Java: close every image first, then release the log + // buffers, then notify handlers. Single-pass close-and-release would let one image's + // ReleaseLogBuffers free memory still being read by a peer image that maps the same + // buffer. foreach (var image in images) { image.Close(); + } + + foreach (var image in images) + { ReleaseLogBuffers(image.LogBuffers, image.CorrelationId, lingerNs); } @@ -1752,7 +1762,13 @@ internal void OnStaticCounter(long correlationId, int counterId) CountersReader countersReader = _aeron.CountersReader; _resourceByRegIdMap.Put( correlationId, - new Counter(countersReader, countersReader.GetCounterRegistrationId(counterId), counterId) + new Counter( + correlationId, + countersReader.GetCounterRegistrationId(counterId), + false, + this, + _counterValuesBuffer, + counterId) ); } @@ -1916,19 +1932,10 @@ private void AwaitResponse(long correlationId) return; } - - try - { - Thread.Sleep(1); - } - catch (ThreadInterruptedException) - { - TerminateConductor(); - throw new AeronException("thread interrupted"); - } } while (deadlineNs - _nanoClock.NanoTime() > 0); - throw new DriverTimeoutException("no response from MediaDriver within " + _driverTimeoutNs + "ns"); + throw new DriverTimeoutException( + "no response from MediaDriver within " + SystemUtil.FormatDuration(_driverTimeoutNs)); } private int CheckTimeouts(long nowNs) @@ -1955,10 +1962,9 @@ private void CheckServiceInterval(long nowNs) throw new ConductorServiceTimeoutException( "service interval exceeded: timeout=" - + _interServiceTimeoutNs - + "ns, actual=" - + (nowNs - _timeOfLastServiceNs) - + "ns" + + SystemUtil.FormatDuration(_interServiceTimeoutNs) + + ", interval=" + + SystemUtil.FormatDuration(nowNs - _timeOfLastServiceNs) ); } } @@ -2010,7 +2016,7 @@ private int CheckLiveness(long nowNs) _countersReader.MetaDataBuffer, counterId, " name=" + _ctx.ClientName() - //+ " " + AeronCounters.formatVersionInfo(AeronVersion.VERSION, AeronVersion.GIT_SHA) + + " " + AeronCounters.FormatVersionInfo(AeronVersion.VERSION, AeronVersion.GIT_SHA) ); _timeOfLastKeepAliveNs = nowNs; } @@ -2082,7 +2088,7 @@ private void ForceCloseResources() publication.InternalClose(); ReleaseLogBuffers(publication.LogBuffers, publication.OriginalRegistrationId, NULL_VALUE); } - else if (resource is Counter counter && this == counter.ClientConductor()) + else if (resource is Counter counter && counter.ClientOwned()) { counter.InternalClose(); NotifyUnavailableCounterHandlers(counter.RegistrationId, counter.Id); diff --git a/src/Adaptive.Aeron/Config.cs b/src/Adaptive.Aeron/Config.cs index 928b1517..4663e30f 100644 --- a/src/Adaptive.Aeron/Config.cs +++ b/src/Adaptive.Aeron/Config.cs @@ -16,6 +16,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using Adaptive.Agrona; namespace Adaptive.Aeron { @@ -29,16 +31,16 @@ static Config() foreach (var a in args) { - if (!a.StartsWith("-D")) + if (!a.StartsWith("-D", StringComparison.Ordinal)) { continue; } - var directive = a.Replace("-D", "").Split('='); + var directive = a.Substring(2).Split(new[] { '=' }, 2); if (directive.Length == 2) { - Params.Add(directive[0], directive[1]); + Params[directive[0]] = directive[1]; } } } @@ -50,7 +52,8 @@ public static string GetProperty(string propertyName, string defaultValue = null public static int GetInteger(string propertyName, int defaultValue) { - return Params.TryGetValue(propertyName, out string strValue) && int.TryParse(strValue, out int value) + return Params.TryGetValue(propertyName, out string strValue) + && int.TryParse(strValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) ? value : defaultValue; } @@ -58,24 +61,58 @@ public static int GetInteger(string propertyName, int defaultValue) public static bool GetBoolean(string propertyName) { var value = GetProperty(propertyName, "false"); - return value.ToLower() == "true"; + return bool.TryParse(value, out var b) && b; } public static long GetLong(string propertyName, long defaultValue) { - return Params.TryGetValue(propertyName, out string strValue) && long.TryParse(strValue, out long value) + return Params.TryGetValue(propertyName, out string strValue) + && long.TryParse(strValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value) ? value : defaultValue; } + /// + /// Returns the property as a duration in nanoseconds, accepting Java suffix notation + /// (ns / us / ms / s) as supported by + /// . Bare integers are interpreted as nanoseconds. + /// public static long GetDurationInNanos(string propertyName, long defaultValue) { - return GetLong(propertyName, defaultValue); + return Params.TryGetValue(propertyName, out var strValue) + ? SystemUtil.ParseDuration(propertyName, strValue) + : defaultValue; } + /// + /// Returns the property as a size in bytes, accepting Java suffix notation + /// (k / m / g) as supported by . + /// Bare integers are interpreted as bytes. + /// public static int GetSizeAsInt(string propertyName, int defaultValue) { - return GetInteger(propertyName, defaultValue); + if (!Params.TryGetValue(propertyName, out var strValue)) + { + return defaultValue; + } + var size = SystemUtil.ParseSize(propertyName, strValue); + if (size > int.MaxValue || size < int.MinValue) + { + throw new FormatException(propertyName + " out of range for int: " + strValue); + } + return (int)size; + } + + /// + /// Returns the property as a size in bytes, accepting Java suffix notation + /// (k / m / g) as supported by . + /// Bare integers are interpreted as bytes. + /// + public static long GetSizeAsLong(string propertyName, long defaultValue) + { + return Params.TryGetValue(propertyName, out var strValue) + ? SystemUtil.ParseSize(propertyName, strValue) + : defaultValue; } } } diff --git a/src/Adaptive.Aeron/Counter.cs b/src/Adaptive.Aeron/Counter.cs index c4954ef0..f8ac7fa8 100644 --- a/src/Adaptive.Aeron/Counter.cs +++ b/src/Adaptive.Aeron/Counter.cs @@ -26,12 +26,21 @@ namespace Adaptive.Aeron public sealed class Counter : AtomicCounter { private readonly ClientConductor _clientConductor; + private readonly bool _clientOwned; private AtomicBoolean _isClosed = new AtomicBoolean(false); - internal Counter(long registrationId, ClientConductor clientConductor, IAtomicBuffer buffer, int counterId) + internal Counter( + long correlationId, + long registrationId, + bool clientOwned, + ClientConductor clientConductor, + IAtomicBuffer buffer, + int counterId) : base(buffer, counterId) { + CorrelationId = correlationId; RegistrationId = registrationId; + _clientOwned = clientOwned; _clientConductor = clientConductor; } @@ -39,11 +48,11 @@ internal Counter(long registrationId, ClientConductor clientConductor, IAtomicBu /// Construct a read-write view of an existing counter. /// /// for getting access to the buffers. - /// assigned by the driver for the counter or - /// if not known. /// for the counter to be viewed. /// if the id has for the counter has not been allocated. - public Counter(CountersReader countersReader, long registrationId, int counterId) + /// Since 1.51.0 — the registrationId parameter was removed; it is now read from the + /// . + public Counter(CountersReader countersReader, int counterId) : base(countersReader.ValuesBuffer, counterId) { if (countersReader.GetCounterState(counterId) != CountersReader.RECORD_ALLOCATED) @@ -51,12 +60,26 @@ public Counter(CountersReader countersReader, long registrationId, int counterId throw new AeronException("Counter id is not allocated: " + counterId); } - RegistrationId = registrationId; + CorrelationId = Aeron.NULL_VALUE; + RegistrationId = countersReader.GetCounterRegistrationId(counterId); + _clientOwned = true; _clientConductor = null; } /// - /// Return the registration id used to register this counter with the media driver. + /// The correlation id of the counter creation command sent to the media driver, or + /// if unknown. + /// + /// Since 1.51.0 + public long CorrelationId { get; } + + /// + /// Return the registration id used to register this counter with the media driver. Can also be retrieved by + /// calling . + /// + /// For non-static counters this is the same as . For static counters this will be a + /// user-defined value specified at creation time. + /// /// /// the registration id used to register this counter with the media driver. public long RegistrationId { get; } @@ -74,7 +97,7 @@ public override void Dispose() { base.Dispose(); - _clientConductor?.ReleaseCounter(this); + _clientConductor?.RemoveCounter(this); } } @@ -90,9 +113,9 @@ internal void InternalClose() _isClosed.Set(true); } - internal ClientConductor ClientConductor() + internal bool ClientOwned() { - return _clientConductor; + return _clientOwned; } } } diff --git a/src/Adaptive.Aeron/Exceptions/AeronException.cs b/src/Adaptive.Aeron/Exceptions/AeronException.cs index 212055a4..046228f5 100644 --- a/src/Adaptive.Aeron/Exceptions/AeronException.cs +++ b/src/Adaptive.Aeron/Exceptions/AeronException.cs @@ -49,6 +49,7 @@ public AeronException(Category category) public AeronException(Exception cause) : base(cause?.ToString(), cause) { + Category = Category.ERROR; } protected AeronException(SerializationInfo info, StreamingContext context) : base(info, context) @@ -57,29 +58,34 @@ protected AeronException(SerializationInfo info, StreamingContext context) : bas } public AeronException(string message) - : base(message) + : base(FormatMessage(message, Category.ERROR)) { Category = Category.ERROR; } public AeronException(string message, Category category) - : base(message) + : base(FormatMessage(message, category)) { Category = category; } public AeronException(string message, Exception innerException) - : base(message, innerException) + : base(FormatMessage(message, Category.ERROR), innerException) { Category = Category.ERROR; } public AeronException(string message, Exception innerException, Category category) - : base(message, innerException) + : base(FormatMessage(message, category), innerException) { Category = category; } + private static string FormatMessage(string message, Category category) + { + return category + " - " + message; + } + /// /// Determines if a is a if an /// . diff --git a/src/Adaptive.Aeron/Image.cs b/src/Adaptive.Aeron/Image.cs index fcc74352..2d9c97db 100644 --- a/src/Adaptive.Aeron/Image.cs +++ b/src/Adaptive.Aeron/Image.cs @@ -849,7 +849,7 @@ public int BlockPoll(BlockHandler handler, int blockLengthLimit) { try { - var termId = termBuffer.GetInt(offset + TERM_ID_FIELD_OFFSET); + var termId = termBuffer.GetInt(offset + TERM_ID_FIELD_OFFSET, ByteOrder.LittleEndian); handler(termBuffer, offset, length, SessionId, termId); } @@ -910,7 +910,7 @@ public int RawPoll(RawBlockHandler handler, int blockLengthLimit) try { long fileOffset = ((long)capacity * activeIndex) + offset; - int termId = termBuffer.GetInt(offset + TERM_ID_FIELD_OFFSET); + int termId = termBuffer.GetInt(offset + TERM_ID_FIELD_OFFSET, ByteOrder.LittleEndian); handler(_logBuffers.FileStream, fileOffset, termBuffer, offset, length, SessionId, termId); } diff --git a/src/Adaptive.Aeron/LogBuffer/LogBufferDescriptor.cs b/src/Adaptive.Aeron/LogBuffer/LogBufferDescriptor.cs index 08615ac6..abf340a3 100644 --- a/src/Adaptive.Aeron/LogBuffer/LogBufferDescriptor.cs +++ b/src/Adaptive.Aeron/LogBuffer/LogBufferDescriptor.cs @@ -74,6 +74,24 @@ public class LogBufferDescriptor /// public const int PAGE_MAX_SIZE = 1024 * 1024 * 1024; + /// + /// Value for the type field to indicate a concurrent publication. + /// + /// Since 1.51.0 + public const byte LOG_BUFFER_TYPE_CONCURRENT_PUBLICATION = 0; + + /// + /// Value for the type field to indicate an exclusive publication. + /// + /// Since 1.51.0 + public const byte LOG_BUFFER_TYPE_EXCLUSIVE_PUBLICATION = 1; + + /// + /// Value for the type field to indicate a subscription. + /// + /// Since 1.51.0 + public const byte LOG_BUFFER_TYPE_PUBLICATION_IMAGE = 2; + /// /// Section index for which buffer contains the log metadata. /// @@ -163,6 +181,15 @@ public class LogBufferDescriptor /// public static readonly int LOG_IS_PUBLICATION_REVOKED_OFFSET; + /// + /// Offset within the log metadata where the type of the log buffer is stored. + /// + /// + /// + /// + /// Since 1.51.0 + public static readonly int LOG_TYPE_OFFSET; + /** * Offset within the log metadata where the rejoin property is stored. */ @@ -416,6 +443,7 @@ static LogBufferDescriptor() LOG_SPIES_SIMULATE_CONNECTION_OFFSET = LOG_SIGNAL_EOS_OFFSET + SIZE_OF_BYTE; LOG_TETHER_OFFSET = LOG_SPIES_SIMULATE_CONNECTION_OFFSET + SIZE_OF_BYTE; LOG_IS_PUBLICATION_REVOKED_OFFSET = LOG_TETHER_OFFSET + SIZE_OF_BYTE; + LOG_TYPE_OFFSET = LOG_IS_PUBLICATION_REVOKED_OFFSET + SIZE_OF_BYTE; LOG_UNTETHERED_LINGER_TIMEOUT_NS_OFFSET = LOG_IS_PUBLICATION_REVOKED_OFFSET + SIZE_OF_INT; LOG_META_DATA_LENGTH = PAGE_MIN_SIZE; @@ -1150,6 +1178,32 @@ public static void IsPublicationRevoked(UnsafeBuffer metadataBuffer, bool value) metadataBuffer.PutByte(LOG_IS_PUBLICATION_REVOKED_OFFSET, (byte)(value ? 1 : 0)); } + /// + /// Get type information from this log buffer. + /// + /// containing the meta data. + /// one of , + /// , or + /// . + /// Since 1.51.0 + public static byte Type(UnsafeBuffer metadataBuffer) + { + return metadataBuffer.GetByte(LOG_TYPE_OFFSET); + } + + /// + /// Set type information for this log buffer. + /// + /// containing the meta data. + /// one of , + /// , or + /// . + /// Since 1.51.0 + public static void Type(UnsafeBuffer metadataBuffer, byte value) + { + metadataBuffer.PutByte(LOG_TYPE_OFFSET, value); + } + /// /// Get whether the log is group from the metadata. /// diff --git a/src/Adaptive.Aeron/PublicAPI.Unshipped.txt b/src/Adaptive.Aeron/PublicAPI.Unshipped.txt index 5ddfd141..9ea5497e 100644 --- a/src/Adaptive.Aeron/PublicAPI.Unshipped.txt +++ b/src/Adaptive.Aeron/PublicAPI.Unshipped.txt @@ -1,6 +1,66 @@ Adaptive.Aeron.ChannelUri.Diff(Adaptive.Aeron.ChannelUri that) -> System.Collections.Generic.IDictionary Adaptive.Aeron.ChannelUriStringBuilder.NakDelay(long? nakDelayNs) -> Adaptive.Aeron.ChannelUriStringBuilder +Adaptive.Aeron.Counter.Counter(Adaptive.Agrona.Concurrent.Status.CountersReader countersReader, int counterId) -> void +Adaptive.Aeron.Counter.CorrelationId.get -> long +Adaptive.Aeron.Status.ReadableCounter.GetPlain() -> long Adaptive.Aeron.LogBuffer.HeaderWriter.HeaderWriter(long versionFlagsType, long sessionId, long streamId) -> void +const Adaptive.Aeron.AeronCounters.ATS_AEAD_ERRORS_TYPE_ID = 69 -> int +const Adaptive.Aeron.AeronCounters.ATS_BYTES_DECRYPTED_TYPE_ID = 68 -> int +const Adaptive.Aeron.AeronCounters.ATS_BYTES_ENCRYPTED_TYPE_ID = 67 -> int +const Adaptive.Aeron.AeronCounters.ATS_DISCARDS_NON_ATS_TYPE_ID = 66 -> int +const Adaptive.Aeron.AeronCounters.ATS_DROPPED_SM_TYPE_ID = 74 -> int +const Adaptive.Aeron.AeronCounters.ATS_EC_KEY_SIG_ERRORS_TYPE_ID = 71 -> int +const Adaptive.Aeron.AeronCounters.ATS_RSA_KEY_UNKNOWN_TYPE_ID = 70 -> int +const Adaptive.Aeron.AeronCounters.ATS_TRANSPORTS_TYPE_ID = 65 -> int +const Adaptive.Aeron.AeronCounters.ATS_UNICAST_RE_KEYING_RSA_KEY_MISMATCH_TYPE_ID = 73 -> int +const Adaptive.Aeron.AeronCounters.ATS_UNICAST_RE_KEYINGS_TYPE_ID = 72 -> int +const Adaptive.Aeron.AeronCounters.DPDK_ARP_MISS_TYPE_ID = 85 -> int +const Adaptive.Aeron.AeronCounters.DPDK_BYTES_TYPE_ID = 83 -> int +const Adaptive.Aeron.AeronCounters.DPDK_CHECKSUM_FAILURE_TYPE_ID = 89 -> int +const Adaptive.Aeron.AeronCounters.DPDK_ERROR_TYPE_ID = 79 -> int +const Adaptive.Aeron.AeronCounters.DPDK_EXTENDED_STATS_TYPE_ID = 92 -> int +const Adaptive.Aeron.AeronCounters.DPDK_FRAGMENTED_PACKETS_TYPE_ID = 90 -> int +const Adaptive.Aeron.AeronCounters.DPDK_MEMPOOL_AVAILABLE_TYPE_ID = 91 -> int +const Adaptive.Aeron.AeronCounters.DPDK_MISSED_PACKETS_TYPE_ID = 84 -> int +const Adaptive.Aeron.AeronCounters.DPDK_NOBUFS_TYPE_ID = 77 -> int +const Adaptive.Aeron.AeronCounters.DPDK_PKTS_TYPE_ID = 82 -> int +const Adaptive.Aeron.AeronCounters.DPDK_POLLER_TYPE_ID = 87 -> int +const Adaptive.Aeron.AeronCounters.DPDK_PORT_INFO_TYPE_ID = 75 -> int +const Adaptive.Aeron.AeronCounters.DPDK_QUEUE_DROP_COUNT_TYPE_ID = 88 -> int +const Adaptive.Aeron.AeronCounters.DPDK_RX_RECEIVER_DISCARD_TYPE_ID = 95 -> int +const Adaptive.Aeron.AeronCounters.DPDK_RX_SENDER_DISCARD_TYPE_ID = 86 -> int +const Adaptive.Aeron.AeronCounters.DPDK_RX_UNSUPPORTED_ETHERNET_TYPE_TYPE_ID = 93 -> int +const Adaptive.Aeron.AeronCounters.DPDK_RX_UNSUPPORTED_PROTOCOL_TYPE_ID = 94 -> int +const Adaptive.Aeron.AeronCounters.DPDK_TRANSPORT_TYPE_ID = 76 -> int +const Adaptive.Aeron.AeronCounters.DPDK_TX_EAGAIN_TYPE_ID = 78 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_PORT_INFO_TYPE_ID = 50 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_RX_BYTES_TYPE_ID = 58 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_RX_DISCARD_TYPE_ID = 55 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_RX_INVALID_TYPE_ID = 56 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_RX_PKTS_TYPE_ID = 57 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_TRANSPORT_TYPE_ID = 51 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_TX_BYTES_TYPE_ID = 60 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_TX_EAGAIN_TYPE_ID = 53 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_TX_ERROR_TYPE_ID = 54 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_TX_NOBUFS_TYPE_ID = 52 -> int +const Adaptive.Aeron.AeronCounters.EF_VI_TX_PKTS_TYPE_ID = 59 -> int +const Adaptive.Aeron.AeronCounters.NAME_RESOLVER_BOOTSTRAP_NEIGHBOR_COUNTER_TYPE_ID = 21 -> int +const Adaptive.Aeron.AeronCounters.PERSISTENT_SUBSCRIPTION_JOIN_DIFFERENCE_TYPE_ID = 115 -> int +const Adaptive.Aeron.AeronCounters.PERSISTENT_SUBSCRIPTION_LIVE_JOINED_COUNT_TYPE_ID = 117 -> int +const Adaptive.Aeron.AeronCounters.PERSISTENT_SUBSCRIPTION_LIVE_LEFT_COUNT_TYPE_ID = 116 -> int +const Adaptive.Aeron.AeronCounters.PERSISTENT_SUBSCRIPTION_STATE_TYPE_ID = 114 -> int +const Adaptive.Aeron.AeronCounters.SELECTOR_CLIENTS_COUNTER_TYPE_ID = 300 -> int +const Adaptive.Aeron.AeronCounters.SELECTOR_CYCLE_TIME_THRESHOLD_EXCEEDED_TYPE_ID = 303 -> int +const Adaptive.Aeron.AeronCounters.SELECTOR_MAX_CYCLE_TIME_TYPE_ID = 302 -> int +const Adaptive.Aeron.AeronCounters.SELECTOR_SUBSCRIPTIONS_COUNTER_TYPE_ID = 301 -> int +const Adaptive.Aeron.AeronCounters.SYSTEM_COUNTER_ID_ASYNC_EXECUTOR_PROXY_FAILS = 45 -> int +const Adaptive.Aeron.AeronCounters.SYSTEM_COUNTER_ID_STATUS_MESSAGES_REJECTED = 44 -> int +const Adaptive.Aeron.AeronCounters.VMA_RX_DATA_COPY_BYTES_TYPE_ID = 63 -> int +const Adaptive.Aeron.AeronCounters.VMA_RX_ZERO_COPY_BYTES_TYPE_ID = 62 -> int +const Adaptive.Aeron.AeronCounters.VMA_TRANSPORTS_TYPE_ID = 61 -> int +const Adaptive.Aeron.LogBuffer.LogBufferDescriptor.LOG_BUFFER_TYPE_CONCURRENT_PUBLICATION = 0 -> byte +const Adaptive.Aeron.LogBuffer.LogBufferDescriptor.LOG_BUFFER_TYPE_EXCLUSIVE_PUBLICATION = 1 -> byte +const Adaptive.Aeron.LogBuffer.LogBufferDescriptor.LOG_BUFFER_TYPE_PUBLICATION_IMAGE = 2 -> byte readonly Adaptive.Aeron.LogBuffer.HeaderWriter._sessionId -> long readonly Adaptive.Aeron.LogBuffer.HeaderWriter._streamId -> long readonly Adaptive.Aeron.LogBuffer.HeaderWriter._versionFlagsType -> long @@ -8,5 +68,36 @@ static Adaptive.Aeron.AeronCounters.AppendVersionInfo(Adaptive.Agrona.IMutableDi static Adaptive.Aeron.AeronCounters.FormatVersionInfo(string fullVersion, string commitHash) -> string static Adaptive.Aeron.AeronCounters.SetReferenceId(Adaptive.Agrona.Concurrent.IAtomicBuffer metaDataBuffer, Adaptive.Agrona.Concurrent.IAtomicBuffer valuesBuffer, int counterId, long referenceId) -> void static Adaptive.Aeron.LogBuffer.HeaderWriter.NewInstance(Adaptive.Agrona.Concurrent.UnsafeBuffer defaultHeader) -> Adaptive.Aeron.LogBuffer.HeaderWriter +static Adaptive.Aeron.LogBuffer.LogBufferDescriptor.Type(Adaptive.Agrona.Concurrent.UnsafeBuffer metadataBuffer) -> byte +static Adaptive.Aeron.LogBuffer.LogBufferDescriptor.Type(Adaptive.Agrona.Concurrent.UnsafeBuffer metadataBuffer, byte value) -> void +static readonly Adaptive.Aeron.LogBuffer.LogBufferDescriptor.LOG_TYPE_OFFSET -> int virtual Adaptive.Aeron.LogBuffer.HeaderWriter.Write(Adaptive.Agrona.Concurrent.UnsafeBuffer termBuffer, int offset, int length, int termId) -> void +*REMOVED*Adaptive.Aeron.Counter.Counter(Adaptive.Agrona.Concurrent.Status.CountersReader countersReader, long registrationId, int counterId) -> void *REMOVED*Adaptive.Aeron.LogBuffer.HeaderWriter.Write(Adaptive.Agrona.Concurrent.UnsafeBuffer termBuffer, int offset, int length, int termId) -> void +*REMOVED*const Adaptive.Aeron.AeronVersion.MINOR_VERSION = 49 -> int +*REMOVED*const Adaptive.Aeron.AeronVersion.VERSION = "1.49.0" -> string +const Adaptive.Aeron.AeronVersion.MINOR_VERSION = 51 -> int +const Adaptive.Aeron.AeronVersion.VERSION = "1.51.0" -> string +static readonly Adaptive.Aeron.AeronVersion.GIT_SHA -> string +static Adaptive.Aeron.Config.GetSizeAsLong(string propertyName, long defaultValue) -> long +Adaptive.Aeron.Security.SimpleAuthenticator +Adaptive.Aeron.Security.SimpleAuthenticator.OnChallengedSession(Adaptive.Aeron.Security.ISessionProxy sessionProxy, long nowMs) -> void +Adaptive.Aeron.Security.SimpleAuthenticator.OnChallengeResponse(long sessionId, byte[] encodedCredentials, long nowMs) -> void +Adaptive.Aeron.Security.SimpleAuthenticator.OnConnectedSession(Adaptive.Aeron.Security.ISessionProxy sessionProxy, long nowMs) -> void +Adaptive.Aeron.Security.SimpleAuthenticator.OnConnectRequest(long sessionId, byte[] encodedCredentials, long nowMs) -> void +Adaptive.Aeron.Security.SimpleAuthenticator.Builder +Adaptive.Aeron.Security.SimpleAuthenticator.Builder.Builder() -> void +Adaptive.Aeron.Security.SimpleAuthenticator.Builder.Principal(byte[] encodedPrincipal, byte[] encodedCredentials) -> Adaptive.Aeron.Security.SimpleAuthenticator.Builder +Adaptive.Aeron.Security.SimpleAuthenticator.Builder.NewInstance() -> Adaptive.Aeron.Security.SimpleAuthenticator +Adaptive.Aeron.Security.SimpleAuthorisationService +Adaptive.Aeron.Security.SimpleAuthorisationService.IsAuthorised(int protocolId, int actionId, object type, byte[] encodedPrincipal) -> bool +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.Builder() -> void +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.WithDefaultAuthorisation(Adaptive.Aeron.Security.IAuthorisationService defaultAuthorisation) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.AddPrincipalRule(int protocolId, int actionId, object type, byte[] encodedPrincipal, bool isAllowed) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.AddPrincipalRule(int protocolId, int actionId, byte[] encodedPrincipal, bool isAllowed) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.AddPrincipalRule(int protocolId, byte[] encodedPrincipal, bool isAllowed) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.AddGeneralRule(int protocolId, int actionId, object type, bool isAllowed) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.AddGeneralRule(int protocolId, int actionId, bool isAllowed) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.AddGeneralRule(int protocolId, bool isAllowed) -> Adaptive.Aeron.Security.SimpleAuthorisationService.Builder +Adaptive.Aeron.Security.SimpleAuthorisationService.Builder.NewInstance() -> Adaptive.Aeron.Security.SimpleAuthorisationService diff --git a/src/Adaptive.Aeron/Security/SimpleAuthenticator.cs b/src/Adaptive.Aeron/Security/SimpleAuthenticator.cs new file mode 100644 index 00000000..c4535ac7 --- /dev/null +++ b/src/Adaptive.Aeron/Security/SimpleAuthenticator.cs @@ -0,0 +1,184 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; + +// Direct port from io.aeron.security.SimpleAuthenticator. Internal field naming kept +// PascalCase for parity; line lengths kept to Java structure. +#pragma warning disable IDE1006, IDE0041, S103 +namespace Adaptive.Aeron.Security +{ + /// + /// An authenticator that works off a simple principal/credential pair constructed by a builder. + /// It only supports simple authentication, not challenge/response. + /// + public sealed class SimpleAuthenticator : IAuthenticator + { + private readonly Dictionary _principalsByCredentialsMap; + private readonly Dictionary _authenticatedSessionIdToPrincipalMap = new Dictionary(); + + private SimpleAuthenticator(Dictionary principalsByCredentials) + { + _principalsByCredentialsMap = principalsByCredentials; + } + + public void OnConnectRequest(long sessionId, byte[] encodedCredentials, long nowMs) + { + if (_principalsByCredentialsMap.TryGetValue(new Credentials(encodedCredentials), out var principal) + && principal.CredentialsMatch(encodedCredentials)) + { + _authenticatedSessionIdToPrincipalMap[sessionId] = principal; + } + } + + public void OnChallengeResponse(long sessionId, byte[] encodedCredentials, long nowMs) + { + throw new NotSupportedException(); + } + + public void OnConnectedSession(ISessionProxy sessionProxy, long nowMs) + { + var sessionId = sessionProxy.SessionId(); + + if (_authenticatedSessionIdToPrincipalMap.TryGetValue(sessionId, out var principal)) + { + if (sessionProxy.Authenticate(principal._encodedPrincipal)) + { + _authenticatedSessionIdToPrincipalMap.Remove(sessionId); + } + } + else + { + sessionProxy.Reject(); + } + } + + public void OnChallengedSession(ISessionProxy sessionProxy, long nowMs) + { + throw new NotSupportedException(); + } + + /// + /// Builder to create instances of . + /// + public sealed class Builder + { + private readonly Dictionary _principalsByCredentials = new Dictionary(); + + /// + /// Add a principal/credentials pair to the list supported by this authenticator. + /// + /// keys principals by credentials, so encoded credentials + /// should include the encoded principal. The associated used + /// on the client should encode credentials matching this form. + /// + /// + public Builder Principal(byte[] encodedPrincipal, byte[] encodedCredentials) + { + var principal = new Principal(encodedPrincipal, encodedCredentials); + _principalsByCredentials[principal._credentials] = principal; + return this; + } + + /// + /// Construct a new instance of the . + /// + public SimpleAuthenticator NewInstance() + { + return new SimpleAuthenticator(new Dictionary(_principalsByCredentials)); + } + } + + private sealed class Principal + { + internal readonly byte[] _encodedPrincipal; + internal readonly Credentials _credentials; + + internal Principal(byte[] encodedPrincipal, byte[] encodedCredentials) + { + _encodedPrincipal = encodedPrincipal; + _credentials = new Credentials(encodedCredentials); + } + + internal bool CredentialsMatch(byte[] encodedCredentials) + { + return ByteArrayEquals(_credentials._encodedCredentials, encodedCredentials); + } + } + + private sealed class Credentials : IEquatable + { + internal readonly byte[] _encodedCredentials; + private readonly int _hashCode; + + internal Credentials(byte[] encodedCredentials) + { + _encodedCredentials = encodedCredentials; + _hashCode = ComputeHash(encodedCredentials); + } + + public bool Equals(Credentials other) + { + return !ReferenceEquals(other, null) && ByteArrayEquals(_encodedCredentials, other._encodedCredentials); + } + + public override bool Equals(object obj) + { + return obj is Credentials other && Equals(other); + } + + public override int GetHashCode() + { + return _hashCode; + } + + private static int ComputeHash(byte[] bytes) + { + if (bytes == null) + { + return 0; + } + int h = 1; + foreach (var b in bytes) + { + h = 31 * h + b; + } + return h; + } + } + + private static bool ByteArrayEquals(byte[] a, byte[] b) + { + if (ReferenceEquals(a, b)) + { + return true; + } + if (a == null || b == null || a.Length != b.Length) + { + return false; + } + for (var i = 0; i < a.Length; i++) + { + if (a[i] != b[i]) + { + return false; + } + } + return true; + } + } +} diff --git a/src/Adaptive.Aeron/Security/SimpleAuthorisationService.cs b/src/Adaptive.Aeron/Security/SimpleAuthorisationService.cs new file mode 100644 index 00000000..d67bb9f2 --- /dev/null +++ b/src/Adaptive.Aeron/Security/SimpleAuthorisationService.cs @@ -0,0 +1,244 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; + +// Direct port from io.aeron.security.SimpleAuthorisationService. Internal field naming +// kept PascalCase for parity; line lengths kept to Java structure. +#pragma warning disable IDE1006, IDE0041, S103 +namespace Adaptive.Aeron.Security +{ + /// + /// Authorisation service that supports setting general and per-principal rules scoped to protocol, + /// action, and type. Uses a fluent API to add authorisation rules. + /// + public sealed class SimpleAuthorisationService : IAuthorisationService + { + private readonly IAuthorisationService _defaultAuthorisation; + private readonly Dictionary _principalByKeyMap; + private readonly Principal _defaultPrincipal; + + private SimpleAuthorisationService(Builder builder) + { + _defaultAuthorisation = builder._defaultAuthorisation; + _principalByKeyMap = new Dictionary(builder._principalByKeyMap); + _defaultPrincipal = builder._defaultPrincipal; + } + + public bool IsAuthorised(int protocolId, int actionId, object type, byte[] encodedPrincipal) + { + _principalByKeyMap.TryGetValue(new ByteArrayKey(encodedPrincipal), out var principal); + var result = IsAuthorised(principal, protocolId, actionId, type); + if (result.HasValue) + { + return result.Value; + } + + result = IsAuthorised(_defaultPrincipal, protocolId, actionId, type); + if (result.HasValue) + { + return result.Value; + } + + return _defaultAuthorisation.IsAuthorised(protocolId, actionId, type, encodedPrincipal); + } + + private static bool? IsAuthorised(Principal principal, int protocolId, int actionId, object type) + { + return principal?.IsAuthorised(protocolId, actionId, type); + } + + /// + /// Builder to create the authorisation service. + /// + public sealed class Builder + { + internal IAuthorisationService _defaultAuthorisation = DenyAllAuthorisationService.INSTANCE; + internal readonly Dictionary _principalByKeyMap = + new Dictionary(); + internal readonly Principal _defaultPrincipal = new Principal(Array.Empty()); + + public Builder WithDefaultAuthorisation(IAuthorisationService defaultAuthorisation) + { + _defaultAuthorisation = defaultAuthorisation; + return this; + } + + public Builder AddPrincipalRule( + int protocolId, int actionId, object type, byte[] encodedPrincipal, bool isAllowed) + { + var principal = GetOrAddPrincipal(encodedPrincipal); + var byTypeMap = isAllowed + ? principal._byProtocolActionTypeAllowed + : principal._byProtocolActionTypeDenied; + GetOrAddSet(byTypeMap, protocolId, actionId).Add(type); + return this; + } + + public Builder AddPrincipalRule(int protocolId, int actionId, byte[] encodedPrincipal, bool isAllowed) + { + GetOrAddPrincipal(encodedPrincipal)._byProtocolAction[(protocolId, actionId)] = isAllowed; + return this; + } + + public Builder AddPrincipalRule(int protocolId, byte[] encodedPrincipal, bool isAllowed) + { + GetOrAddPrincipal(encodedPrincipal)._byProtocol[protocolId] = isAllowed; + return this; + } + + public Builder AddGeneralRule(int protocolId, int actionId, object type, bool isAllowed) + { + var byTypeMap = isAllowed + ? _defaultPrincipal._byProtocolActionTypeAllowed + : _defaultPrincipal._byProtocolActionTypeDenied; + GetOrAddSet(byTypeMap, protocolId, actionId).Add(type); + return this; + } + + public Builder AddGeneralRule(int protocolId, int actionId, bool isAllowed) + { + _defaultPrincipal._byProtocolAction[(protocolId, actionId)] = isAllowed; + return this; + } + + public Builder AddGeneralRule(int protocolId, bool isAllowed) + { + _defaultPrincipal._byProtocol[protocolId] = isAllowed; + return this; + } + + public SimpleAuthorisationService NewInstance() + { + return new SimpleAuthorisationService(this); + } + + private Principal GetOrAddPrincipal(byte[] encodedPrincipal) + { + var key = new ByteArrayKey(encodedPrincipal); + if (!_principalByKeyMap.TryGetValue(key, out var principal)) + { + principal = new Principal(encodedPrincipal); + _principalByKeyMap[key] = principal; + } + return principal; + } + + private static HashSet GetOrAddSet( + Dictionary<(int, int), HashSet> map, int protocolId, int actionId) + { + var key = (protocolId, actionId); + if (!map.TryGetValue(key, out var set)) + { + set = new HashSet(); + map[key] = set; + } + return set; + } + } + + internal sealed class Principal + { + internal readonly Dictionary _byProtocol = new Dictionary(); + internal readonly Dictionary<(int, int), bool> _byProtocolAction = new Dictionary<(int, int), bool>(); + internal readonly Dictionary<(int, int), HashSet> _byProtocolActionTypeAllowed = + new Dictionary<(int, int), HashSet>(); + internal readonly Dictionary<(int, int), HashSet> _byProtocolActionTypeDenied = + new Dictionary<(int, int), HashSet>(); + internal readonly byte[] _encodedPrincipal; + + internal Principal(byte[] encodedPrincipal) + { + _encodedPrincipal = encodedPrincipal; + } + + internal bool? IsAuthorised(int protocolId, int actionId, object type) + { + if (_byProtocolActionTypeAllowed.TryGetValue((protocolId, actionId), out var allowed) + && allowed.Contains(type)) + { + return true; + } + + if (_byProtocolActionTypeDenied.TryGetValue((protocolId, actionId), out var denied) + && denied.Contains(type)) + { + return false; + } + + if (_byProtocolAction.TryGetValue((protocolId, actionId), out var authorised)) + { + return authorised; + } + + return _byProtocol.TryGetValue(protocolId, out var byProtocol) ? byProtocol : (bool?)null; + } + } + + internal sealed class ByteArrayKey : IEquatable + { + private readonly byte[] _data; + private readonly int _hashCode; + + internal ByteArrayKey(byte[] data) + { + _data = data ?? Array.Empty(); + _hashCode = ComputeHash(_data); + } + + public bool Equals(ByteArrayKey other) + { + if (ReferenceEquals(other, null)) + { + return false; + } + if (_data.Length != other._data.Length) + { + return false; + } + for (var i = 0; i < _data.Length; i++) + { + if (_data[i] != other._data[i]) + { + return false; + } + } + return true; + } + + public override bool Equals(object obj) + { + return obj is ByteArrayKey other && Equals(other); + } + + public override int GetHashCode() + { + return _hashCode; + } + + private static int ComputeHash(byte[] bytes) + { + var h = 1; + foreach (var b in bytes) + { + h = 31 * h + b; + } + return h; + } + } + } +} diff --git a/src/Adaptive.Aeron/Status/PublicationErrorFrame.cs b/src/Adaptive.Aeron/Status/PublicationErrorFrame.cs index 68852ef2..58b59810 100644 --- a/src/Adaptive.Aeron/Status/PublicationErrorFrame.cs +++ b/src/Adaptive.Aeron/Status/PublicationErrorFrame.cs @@ -155,7 +155,7 @@ public object Clone() public override string ToString() { return - "CounterMessageFlyweight{" + + "PublicationErrorFrame{" + "registrationId=" + _registrationId + ", sessionId=" + _sessionId + ", streamId=" + _streamId + diff --git a/src/Adaptive.Aeron/Status/ReadableCounter.cs b/src/Adaptive.Aeron/Status/ReadableCounter.cs index 10cbbba7..1b7b377b 100644 --- a/src/Adaptive.Aeron/Status/ReadableCounter.cs +++ b/src/Adaptive.Aeron/Status/ReadableCounter.cs @@ -124,12 +124,23 @@ public long Get() /// /// Get the value of the counter using weak ordering semantics. This is the same a standard read of a field. + /// + /// This call is identical to and that method is preferred. + /// /// /// the value for the counter. public long GetWeak() { - // UnsafeAccess.UNSAFE.getLong(buffer, addressOffset); + return GetPlain(); + } + /// + /// Get the value of the counter using plain memory semantics. This is the same a standard read of a field. + /// + /// the value for the counter. + /// Since 1.51.0 + public long GetPlain() + { return _valueBuffer.GetLong(0); } diff --git a/src/Adaptive.Aeron/Subscription.cs b/src/Adaptive.Aeron/Subscription.cs index 692e1d69..fa949fde 100644 --- a/src/Adaptive.Aeron/Subscription.cs +++ b/src/Adaptive.Aeron/Subscription.cs @@ -43,6 +43,9 @@ internal struct SubscriptionFields internal readonly string _channel; internal readonly AvailableImageHandler _availableImageHandler; internal readonly UnavailableImageHandler _unavailableImageHandler; + internal ChannelUri _channelUri; + internal string _resolvedChannel; + internal string _resolvedEndpoint; internal int _channelStatusId; // padding to prevent false sharing @@ -65,12 +68,14 @@ UnavailableImageHandler unavailableImageHandler _streamId = streamId; _conductor = clientConductor; _channel = channel; + _channelUri = null; + _resolvedChannel = null; + _resolvedEndpoint = null; _availableImageHandler = availableImageHandler; _unavailableImageHandler = unavailableImageHandler; _roundRobinIndex = 0; _isClosed = false; _images = EmptyImages; - _channelStatusId = 0; } } @@ -389,7 +394,10 @@ public Image ImageBySessionId(int sessionId) /// image at given index. public Image ImageAtIndex(int index) { - return Images[index]; + // Direct array access matches Java's `images[index]`. The Images property allocates + // a ReadOnlyCollection wrapper on every call; that's wasted work on hot poll paths + // (PersistentSubscription.Replay()/AttemptSwitch() hits this every tick per image). + return _fields._images[index]; } /// @@ -549,64 +557,70 @@ public long AsyncRemoveDestination(string endpointChannel) /// /// Resolve channel endpoint and replace it with the port from the ephemeral range when 0 was provided. If there /// are no addresses, or if there is more than one, returned from then - /// the original - /// is returned. - /// - /// If the channel is not , then {@code null} will be returned. - /// - /// /// - /// channel URI string with an endpoint being resolved to the allocated port. + /// original channel URI string if it does not have an endpoint (IPC, response channel, MDS) or if + /// endpoint is not using an ephemeral port, channel URI where ephemeral port is resolved to the actual bind + /// port or null if the channel is not . /// /// + /// Since 1.51.0 — caches the resolved channel/endpoint and short-circuits when the endpoint is + /// missing, not ephemeral, or MDS manual control mode. public string TryResolveChannelEndpointPort() { - long channelStatus = ChannelStatus; - - if (ChannelEndpointStatus.ACTIVE == channelStatus) + if (null == _fields._resolvedChannel) { - IList localSocketAddresses = LocalSocketAddressStatus.FindAddresses( - _fields._conductor.CountersReader(), - channelStatus, - _fields._channelStatusId - ); - - if (1 == localSocketAddresses.Count) + if (null == _fields._channelUri) { ChannelUri uri = ChannelUri.Parse(_fields._channel); string endpoint = uri.Get(Aeron.Context.ENDPOINT_PARAM_NAME); - - if (null != endpoint && endpoint.EndsWith(":0", StringComparison.Ordinal)) + if (null == endpoint || + !endpoint.EndsWith(":0", StringComparison.Ordinal) || + Aeron.Context.MDC_CONTROL_MODE_MANUAL.Equals( + uri.Get(Aeron.Context.MDC_CONTROL_MODE_PARAM_NAME), + StringComparison.Ordinal)) { - uri.ReplaceEndpointWildcardPort(localSocketAddresses[0]); - return uri.ToString(); + _fields._resolvedChannel = _fields._channel; + return _fields._channel; } + _fields._channelUri = uri; } - return _fields._channel; + string resolved = ResolvedEndpoint; + if (null != resolved) + { + _fields._channelUri.ReplaceEndpointWildcardPort(resolved); + _fields._resolvedChannel = _fields._channelUri.ToString(); + } } - - return null; + return _fields._resolvedChannel; } /// - /// Find the resolved endpoint for the channel. This may be null if MDS is used and no destination is yet added. - /// The result will similar to taking the first element returned from . - /// If more than one destination is added then the first found is returned. - /// - /// If the channel is not , then {@code null} will be returned. - /// - /// + /// Find the resolved endpoint for the channel. This may be null if MDS is used and no destination is yet + /// added. The result will similar to taking the first element returned from + /// . If more than one destination is added then the first found is + /// returned. /// - /// The resolved endpoint or null if not found. + /// resolved endpoint or null if not found. /// /// - public string ResolvedEndpoint => - LocalSocketAddressStatus.FindAddress( - _fields._conductor.CountersReader(), - ChannelStatus, - _fields._channelStatusId - ); + /// Since 1.51.0 — caches the resolved endpoint after the first successful lookup. + public string ResolvedEndpoint + { + get + { + if (null == _fields._resolvedEndpoint) + { + _fields._resolvedEndpoint = LocalSocketAddressStatus.FindAddress( + _fields._conductor.CountersReader(), + ChannelStatus, + _fields._channelStatusId + ); + } + + return _fields._resolvedEndpoint; + } + } internal void InternalClose(long lingerDurationNs) { diff --git a/src/Adaptive.Agrona/Adaptive.Agrona.csproj b/src/Adaptive.Agrona/Adaptive.Agrona.csproj index a07e1dcd..7ffca044 100644 --- a/src/Adaptive.Agrona/Adaptive.Agrona.csproj +++ b/src/Adaptive.Agrona/Adaptive.Agrona.csproj @@ -3,7 +3,7 @@ netstandard2.0 true Agrona - 1.49.0 + 1.51.0 Adaptive Financial Consulting Ltd. Adaptive Financial Consulting Ltd. Agrona libraries initially included in Aeron Client diff --git a/src/Adaptive.Agrona/Concurrent/Status/AtomicCounter.cs b/src/Adaptive.Agrona/Concurrent/Status/AtomicCounter.cs index aa0a7b60..d607de52 100644 --- a/src/Adaptive.Agrona/Concurrent/Status/AtomicCounter.cs +++ b/src/Adaptive.Agrona/Concurrent/Status/AtomicCounter.cs @@ -83,6 +83,15 @@ public long IncrementOrdered() return _buffer.AddLongOrdered(_offset, 1); } + /// + /// Perform an atomic increment with release ordering semantics. + /// + /// the previous value of the counter + public long IncrementRelease() + { + return _buffer.AddLongOrdered(_offset, 1); + } + /// /// Set the counter with volatile semantics. /// diff --git a/src/Adaptive.Agrona/PublicAPI.Unshipped.txt b/src/Adaptive.Agrona/PublicAPI.Unshipped.txt index bbd2ceff..8c5a4a87 100644 --- a/src/Adaptive.Agrona/PublicAPI.Unshipped.txt +++ b/src/Adaptive.Agrona/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +Adaptive.Agrona.Concurrent.Status.AtomicCounter.IncrementRelease() -> long Adaptive.Agrona.SuppressedExceptions static Adaptive.Agrona.SuppressedExceptions.AddSuppressed(this System.Exception primary, System.Exception suppressed) -> void static Adaptive.Agrona.SuppressedExceptions.GetSuppressed(this System.Exception primary) -> System.Collections.Generic.IReadOnlyList diff --git a/src/Adaptive.Archiver.IntegrationTests/Adaptive.Archiver.IntegrationTests.csproj b/src/Adaptive.Archiver.IntegrationTests/Adaptive.Archiver.IntegrationTests.csproj new file mode 100644 index 00000000..3c99a519 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Adaptive.Archiver.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + false + Adaptive.Archiver.IntegrationTests + + + + + + + + + + + + + + + diff --git a/src/Adaptive.Archiver.IntegrationTests/ControlledPollingPersistentSubscriptionTest.cs b/src/Adaptive.Archiver.IntegrationTests/ControlledPollingPersistentSubscriptionTest.cs new file mode 100644 index 00000000..e051f086 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/ControlledPollingPersistentSubscriptionTest.cs @@ -0,0 +1,30 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Adaptive.Aeron.LogBuffer; +using NUnit.Framework; + +namespace Adaptive.Archiver.IntegrationTests +{ + [TestFixture] + internal class ControlledPollingPersistentSubscriptionTest : PersistentSubscriptionTest + { + protected override int Poll(PersistentSubscription subscription, IFragmentHandler handler, int fragmentLimit) + { + return subscription.ControlledPoll((IControlledFragmentHandler)handler, fragmentLimit); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Helpers/BackgroundPublisher.cs b/src/Adaptive.Archiver.IntegrationTests/Helpers/BackgroundPublisher.cs new file mode 100644 index 00000000..09eec5f9 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Helpers/BackgroundPublisher.cs @@ -0,0 +1,104 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Adaptive.Agrona.Concurrent; + +namespace Adaptive.Archiver.IntegrationTests.Helpers +{ + /// + /// Background publisher used by the network-problems / live-and-replay-advancing tests. + /// Mirrors the upstream Java test pattern: each message is length-randomised in + /// [16, 2048] bytes, with a monotonically increasing message id written into both the + /// first and last 8 bytes — uses those to detect drops, + /// reorders, or corruption. + /// + internal sealed class BackgroundPublisher : IDisposable + { + private readonly PersistentPublication _publication; + private readonly int _ratePerSecond; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _task; + + public BackgroundPublisher(PersistentPublication publication, int ratePerSecond) + { + _publication = publication; + _ratePerSecond = ratePerSecond; + _task = Task.Run(Run); + } + + private void Run() + { + var random = new Random(); + var buffer = new UnsafeBuffer(new byte[2048]); + long messageId = 0; + var nextMessageAtTicks = Stopwatch.GetTimestamp() + ToTicks(ExponentialArrivalDelayNanos(_ratePerSecond)); + + while (!_cts.IsCancellationRequested) + { + var now = Stopwatch.GetTimestamp(); + if (now - nextMessageAtTicks >= 0) + { + int length; + lock (random) { length = random.Next(2 * sizeof(long), buffer.Capacity + 1); } + buffer.PutLong(0, messageId); + buffer.PutLong(length - sizeof(long), messageId); + var result = _publication.Offer(buffer, 0, length); + if (result > 0) + { + messageId++; + nextMessageAtTicks = now + ToTicks(ExponentialArrivalDelayNanos(_ratePerSecond)); + } + } + } + } + + private static long ToTicks(long nanos) + { + return nanos * Stopwatch.Frequency / 1_000_000_000L; + } + + private static long ExponentialArrivalDelayNanos(long ratePerSecond) + { + var uniform = ThreadLocalRandomDouble(); + var secondFraction = -Math.Log(1.0 - uniform) / ratePerSecond; + return (long)(secondFraction * 1e9); + } + + [ThreadStatic] + private static Random s_threadRandom; + private static double ThreadLocalRandomDouble() + { + s_threadRandom ??= new Random(); + return s_threadRandom.NextDouble(); + } + + private int _disposed; + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + _cts.Cancel(); + try { _task.Wait(TimeSpan.FromSeconds(5)); } catch { } + _cts.Dispose(); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Helpers/BufferingFragmentHandler.cs b/src/Adaptive.Archiver.IntegrationTests/Helpers/BufferingFragmentHandler.cs new file mode 100644 index 00000000..705dfc96 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Helpers/BufferingFragmentHandler.cs @@ -0,0 +1,44 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using Adaptive.Aeron.LogBuffer; +using Adaptive.Agrona; + +namespace Adaptive.Archiver.IntegrationTests.Helpers +{ + internal sealed class BufferingFragmentHandler : IFragmentHandler, IControlledFragmentHandler + { + public List ReceivedPayloads { get; } = new(); + public long Position { get; private set; } + + public ControlledFragmentHandlerAction OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + Position = header.Position; + var bytes = new byte[length]; + buffer.GetBytes(offset, bytes); + ReceivedPayloads.Add(bytes); + return ControlledFragmentHandlerAction.CONTINUE; + } + + void IFragmentHandler.OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + OnFragment(buffer, offset, length, header); + } + + public bool HasReceivedPayloads(int numberOfPayloads) => ReceivedPayloads.Count >= numberOfPayloads; + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Helpers/CountingFragmentHandler.cs b/src/Adaptive.Archiver.IntegrationTests/Helpers/CountingFragmentHandler.cs new file mode 100644 index 00000000..846d8db9 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Helpers/CountingFragmentHandler.cs @@ -0,0 +1,33 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Adaptive.Aeron.LogBuffer; +using Adaptive.Agrona; + +namespace Adaptive.Archiver.IntegrationTests.Helpers +{ + internal sealed class CountingFragmentHandler : IFragmentHandler + { + public int ReceivedFragments { get; private set; } + + public void OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + ReceivedFragments++; + } + + public bool HasReceivedPayloads(int numberOfPayloads) => ReceivedFragments >= numberOfPayloads; + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Helpers/MessageVerifier.cs b/src/Adaptive.Archiver.IntegrationTests/Helpers/MessageVerifier.cs new file mode 100644 index 00000000..989d404c --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Helpers/MessageVerifier.cs @@ -0,0 +1,61 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using Adaptive.Aeron.LogBuffer; +using Adaptive.Agrona; + +namespace Adaptive.Archiver.IntegrationTests.Helpers +{ + /// + /// Verifies the message stream produced by : each + /// message must carry a monotonically increasing id in both the first and last 8 bytes. + /// Throws if a fragment is missing, duplicated, or corrupted. + /// + internal sealed class MessageVerifier : IFragmentHandler, IControlledFragmentHandler + { + public long ExpectedMessageId { get; private set; } + public long Position { get; private set; } + + public ControlledFragmentHandlerAction OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + if (length < 2 * sizeof(long)) + { + throw new InvalidOperationException("length was " + length); + } + var messageId1 = buffer.GetLong(offset); + var messageId2 = buffer.GetLong(offset + length - sizeof(long)); + if (messageId1 != messageId2) + { + throw new InvalidOperationException( + "message had different ids " + messageId1 + " and " + messageId2); + } + if (messageId1 != ExpectedMessageId) + { + throw new InvalidOperationException( + "expected id " + ExpectedMessageId + ", but got " + messageId1); + } + ExpectedMessageId = messageId1 + 1; + Position = header.Position; + return ControlledFragmentHandlerAction.CONTINUE; + } + + void IFragmentHandler.OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + OnFragment(buffer, offset, length, header); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Helpers/PersistentPublication.cs b/src/Adaptive.Archiver.IntegrationTests/Helpers/PersistentPublication.cs new file mode 100644 index 00000000..bbce1dd1 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Helpers/PersistentPublication.cs @@ -0,0 +1,202 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using Adaptive.Aeron; +using Adaptive.Agrona; +using Adaptive.Agrona.Concurrent; +using Adaptive.Agrona.Concurrent.Status; +using Adaptive.Archiver.Codecs; +using Adaptive.Archiver.IntegrationTests.Infrastructure; + +namespace Adaptive.Archiver.IntegrationTests.Helpers +{ + /// + /// Port of Java's inner PersistentSubscriptionTest.PersistentPublication: + /// wraps an AeronArchive + ExclusivePublication + recording counter so tests can + /// create a recording, offer messages, and wait for them to be archived. + /// + internal sealed class PersistentPublication : IDisposable + { + private readonly AeronArchive _aeronArchive; + private readonly ExclusivePublication _publication; + private readonly long _recordingId; + private readonly CountersReader _countersReader; + private readonly int _recordingCounterId; + private int _publishedMessageCount; + + private PersistentPublication( + AeronArchive aeronArchive, + ExclusivePublication publication, + long recordingId, + CountersReader countersReader, + int recordingCounterId) + { + _aeronArchive = aeronArchive; + _publication = publication; + _recordingId = recordingId; + _countersReader = countersReader; + _recordingCounterId = recordingCounterId; + } + + public long RecordingId => _recordingId; + public int MaxPayloadLength => _publication.MaxPayloadLength; + public long Position => _publication.Position; + public int PublishedMessageCount => _publishedMessageCount; + public bool IsConnected => _publication.IsConnected; + internal ExclusivePublication ExclusivePub => _publication; + + public bool PublicationCountersExist() + { + var counterId = _countersReader.FindByTypeIdAndRegistrationId( + Adaptive.Aeron.AeronCounters.DRIVER_PUBLISHER_POS_TYPE_ID, _publication.RegistrationId); + return counterId != Adaptive.Agrona.Concurrent.Status.CountersReader.NULL_COUNTER_ID; + } + + public long ReceiverCount() + { + var counterId = _countersReader.FindByTypeIdAndRegistrationId( + AeronCounters.FLOW_CONTROL_RECEIVERS_COUNTER_TYPE_ID, + _publication.RegistrationId); + if (CountersReader.NULL_COUNTER_ID == counterId) + { + throw new System.InvalidOperationException( + "flow-control receivers counter not found for publication " + + _publication.RegistrationId); + } + return _countersReader.GetCounterValue(counterId); + } + + public static PersistentPublication Create(AeronArchive aeronArchive, string channel, int streamId) + { + var publication = aeronArchive.AddRecordedExclusivePublication(channel, streamId); + var countersReader = aeronArchive.Ctx().AeronClient().CountersReader; + var recordingCounterId = Tests.AwaitRecordingCounterId( + countersReader, publication.SessionId, aeronArchive.ArchiveId()); + var recordingId = RecordingPos.GetRecordingId(countersReader, recordingCounterId); + + return new PersistentPublication( + aeronArchive, publication, recordingId, countersReader, recordingCounterId); + } + + public static PersistentPublication Create(AeronArchive aeronArchive, ExclusivePublication publication) + { + var countersReader = aeronArchive.Ctx().AeronClient().CountersReader; + var recordingCounterId = Tests.AwaitRecordingCounterId( + countersReader, publication.SessionId, aeronArchive.ArchiveId()); + var recordingId = RecordingPos.GetRecordingId(countersReader, recordingCounterId); + + return new PersistentPublication( + aeronArchive, publication, recordingId, countersReader, recordingCounterId); + } + + public static PersistentPublication Resume( + AeronArchive aeronArchive, string channel, int streamId, long recordingId) + { + var channelUriBuilder = new ChannelUriStringBuilder(channel); + var descriptor = new ResumeDescriptor(); + aeronArchive.ListRecording(recordingId, descriptor); + channelUriBuilder.InitialPosition( + descriptor.StopPosition, descriptor.InitialTermId, descriptor.TermBufferLength); + + var aeron = aeronArchive.Ctx().AeronClient(); + var channelUri = channelUriBuilder.Build(); + var publication = aeron.AddExclusivePublication(channelUri, streamId); + aeronArchive.ExtendRecording(recordingId, channelUri, streamId, SourceLocation.LOCAL); + + var countersReader = aeron.CountersReader; + var recordingCounterId = Tests.AwaitRecordingCounterId( + countersReader, publication.SessionId, aeronArchive.ArchiveId()); + + return new PersistentPublication( + aeronArchive, publication, recordingId, countersReader, recordingCounterId); + } + + private sealed class ResumeDescriptor : IRecordingDescriptorConsumer + { + public long StopPosition { get; private set; } + public int InitialTermId { get; private set; } + public int TermBufferLength { get; private set; } + + public void OnRecordingDescriptor( + long controlSessionId, long correlationId, long recordingId, long startTimestamp, + long stopTimestamp, long startPosition, long stopPosition, int initialTermId, + int segmentFileLength, int termBufferLength, int mtuLength, int sessionId, + int streamId, string strippedChannel, string originalChannel, string sourceIdentity) + { + StopPosition = stopPosition; + InitialTermId = initialTermId; + TermBufferLength = termBufferLength; + } + } + + public long Offer(IDirectBuffer buffer, int offset, int length) + { + var result = _publication.Offer(buffer, offset, length); + if (result > 0) + { + _publishedMessageCount++; + } + return result; + } + + public void Persist(IList messages) + { + if (0 == messages.Count) + { + return; + } + var position = Publish(messages); + Tests.AwaitPosition(_countersReader, _recordingCounterId, position); + } + + public long Publish(IList messages) + { + var wrapper = new UnsafeBuffer(); + long position = _publication.Position; + + foreach (var message in messages) + { + wrapper.Wrap(message); + while ((position = _publication.Offer(wrapper)) < 0) + { + Tests.YieldingIdle("failed to offer due to " + Publication.ErrorString(position)); + } + } + + _publishedMessageCount += messages.Count; + return position; + } + + public long Stop() + { + _aeronArchive.StopRecording(_publication); + return _aeronArchive.GetStopPosition(_recordingId); + } + + public void Dispose() + { + try { _aeronArchive.StopRecording(_publication); } catch { } + _publication?.Dispose(); + } + + public void ClosePublicationOnly() + { + _publication?.Dispose(); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Helpers/PersistentSubscriptionListener.cs b/src/Adaptive.Archiver.IntegrationTests/Helpers/PersistentSubscriptionListener.cs new file mode 100644 index 00000000..a3cbd1f5 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Helpers/PersistentSubscriptionListener.cs @@ -0,0 +1,38 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; + +namespace Adaptive.Archiver.IntegrationTests.Helpers +{ + internal sealed class PersistentSubscriptionListener : IPersistentSubscriptionListener + { + public int LiveJoinedCount { get; private set; } + public int LiveLeftCount { get; private set; } + public int ErrorCount { get; private set; } + public Exception LastException { get; private set; } + + public void OnLiveJoined() => LiveJoinedCount++; + + public void OnLiveLeft() => LiveLeftCount++; + + public void OnError(Exception e) + { + ErrorCount++; + LastException = e; + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedArchive.cs b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedArchive.cs new file mode 100644 index 00000000..c462e9fe --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedArchive.cs @@ -0,0 +1,292 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using NUnit.Framework; +using AeronClient = Adaptive.Aeron.Aeron; + +namespace Adaptive.Archiver.IntegrationTests.Infrastructure +{ + /// + /// Launches a Java Aeron Archive process from the bundled driver/media-driver.jar + /// (the aeron-all fat jar). Configuration is passed via -Daeron.archive.* system + /// properties at JVM startup, mirroring the way EmbeddedMediaDriver launches the + /// Java media driver. + /// + internal sealed class EmbeddedArchive : IDisposable + { + private const int StartupTimeoutMs = 30_000; + private const int ShutdownTimeoutMs = 10_000; + + // Each instance binds the archive's UDP control channel to a unique random port in the + // ephemeral range so that two tests running back-to-back can't collide if a teardown's + // socket lingers in TIME_WAIT. The response channel uses endpoint :0 (OS-chosen). + // Range chosen to not overlap with the MDC control port range used by the test + // base class (25_000-30_000); see PersistentSubscriptionTest.MdcControlPort. + private const int MinPort = 20_000; + private const int MaxPort = 25_000; + private static readonly Random PortPicker = new(); + + private readonly string _controlChannel; + private const string DefaultControlResponseChannel = "aeron:udp?endpoint=localhost:0"; + private const string DefaultReplicationChannel = "aeron:udp?endpoint=localhost:0"; + + private readonly Process _archive; + private readonly string _archiveDir; + private AeronArchive _probeClient; + + public bool PreserveArchiveDirOnDispose { get; set; } + + public EmbeddedArchive( + string aeronDirectoryName, + string archiveDir = null, + bool deleteArchiveOnStart = true, + string controlChannel = null, + AeronClient aeronClient = null) + { + // If the caller asked to preserve the prior recording (deleteOnStart=false), + // they probably also want to restart against the same dir later; don't blow it + // away on Dispose. + PreserveArchiveDirOnDispose = !deleteArchiveOnStart; + if (string.IsNullOrEmpty(aeronDirectoryName)) + { + throw new ArgumentException("aeronDirectoryName must be set", nameof(aeronDirectoryName)); + } + + _archiveDir = archiveDir + ?? Path.Combine(Path.GetTempPath(), "aeron-archive-" + Guid.NewGuid().ToString("N")); + + if (controlChannel != null) + { + _controlChannel = controlChannel; + } + else + { + int port; + lock (PortPicker) { port = PortPicker.Next(MinPort, MaxPort); } + _controlChannel = $"aeron:udp?endpoint=localhost:{port}"; + } + + // When restarting on an existing archiveDir (deleteOnStart=false) the caller wants + // the prior recordings preserved. Don't pre-clean in that case. + if (deleteArchiveOnStart && Directory.Exists(_archiveDir)) + { + try + { + Directory.Delete(_archiveDir, recursive: true); + } + catch + { + } + } + + var rootDir = + GetSolutionDirectory(TestContext.CurrentContext.TestDirectory)?.Parent + ?? throw new FileNotFoundException("could not find root directory of project"); + var jarPath = Path.Combine(rootDir.FullName, "driver", "media-driver.jar"); + + var psi = new ProcessStartInfo + { + FileName = "java", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/jdk.internal.misc=ALL-UNNAMED"); + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/java.util.zip=ALL-UNNAMED"); + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/java.lang.reflect=ALL-UNNAMED"); + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/sun.nio.ch=ALL-UNNAMED"); + psi.ArgumentList.Add("-cp"); + psi.ArgumentList.Add(jarPath); + psi.ArgumentList.Add($"-Daeron.dir={aeronDirectoryName}"); + psi.ArgumentList.Add($"-Daeron.archive.dir={_archiveDir}"); + psi.ArgumentList.Add($"-Daeron.archive.control.channel={_controlChannel}"); + psi.ArgumentList.Add($"-Daeron.archive.control.response.channel={DefaultControlResponseChannel}"); + psi.ArgumentList.Add($"-Daeron.archive.replication.channel={DefaultReplicationChannel}"); + psi.ArgumentList.Add("-Daeron.archive.threading.mode=SHARED"); + var deleteFlag = deleteArchiveOnStart ? "true" : "false"; + psi.ArgumentList.Add($"-Daeron.archive.delete.archive.dir.on.start={deleteFlag}"); + psi.ArgumentList.Add("io.aeron.archive.Archive"); + + _archive = Process.Start(psi) ?? throw new InvalidOperationException("failed to start aeron archive"); + + WaitForArchiveReady(aeronDirectoryName, aeronClient); + } + + public string ArchiveDir => _archiveDir; + + public string ControlRequestChannel => _controlChannel; + + public string ControlResponseChannel => DefaultControlResponseChannel; + + public AeronArchive.Context CreateClientContext(string aeronDirectoryName) => + new AeronArchive.Context() + .ControlRequestChannel(_controlChannel) + .ControlResponseChannel(DefaultControlResponseChannel) + .AeronDirectoryName(aeronDirectoryName); + + /// + /// Kills the archive JVM without disposing this wrapper or deleting the archive dir. + /// Lets tests preserve recordings across an archive restart. The archive-mark.dat + /// file is removed after the JVM exits because SIGKILL skips Java's graceful + /// cleanup — a fresh archive restarting on this dir would otherwise see a recent + /// heartbeat and reject the dir as "active". + /// + public void KillProcess() + { + // Dispose the probe before killing the JVM so its pub/sub are cleanly removed + // from the conductor before the process disappears under them. + try { _probeClient?.Dispose(); } catch { } + _probeClient = null; + + ShutdownProcess(_archive, "EmbeddedArchive"); + + try + { + var markFile = Path.Combine(_archiveDir, "archive-mark.dat"); + if (File.Exists(markFile)) + { + File.Delete(markFile); + } + } + catch + { + } + } + + public void Dispose() + { + ShutdownProcess(_archive, "EmbeddedArchive"); + + // Dispose the probe after the JVM is dead so there is no window between + // REMOVE_PUBLICATION and a subsequent ADD_PUBLICATION to the same channel. + try { _probeClient?.Dispose(); } catch { } + _probeClient = null; + + bool exited = false; + try { exited = _archive.HasExited; } catch { } + + _archive.Dispose(); + + if (exited && !PreserveArchiveDirOnDispose) + { + try + { + if (Directory.Exists(_archiveDir)) + { + Directory.Delete(_archiveDir, recursive: true); + } + } + catch + { + } + } + } + + internal static void ShutdownProcess(Process process, string name) + { + try + { + if (!process.HasExited) + { + // Process.Kill maps to SIGKILL on Unix and TerminateProcess on Windows; + // neither can be ignored by the target. entireProcessTree is best-effort — + // safe on both platforms. + process.Kill(entireProcessTree: true); + } + } + catch + { + } + + try { process.WaitForExit(ShutdownTimeoutMs); } catch { } + + try + { + if (!process.HasExited) + { + NUnit.Framework.TestContext.Progress.WriteLine( + $"WARNING: {name} JVM pid={process.Id} did not exit within {ShutdownTimeoutMs}ms after Kill"); + } + } + catch + { + // Process object may already be disposed (e.g. by an outer using); not our problem. + } + } + + private void WaitForArchiveReady(string aeronDirectoryName, AeronClient aeronClient) + { + var deadline = DateTime.UtcNow.AddMilliseconds(StartupTimeoutMs); + Exception last = null; + + while (DateTime.UtcNow < deadline) + { + if (_archive.HasExited) + { + string stderr = ""; + try { stderr = _archive.StandardError.ReadToEnd(); } catch { } + throw new InvalidOperationException( + $"archive process exited prematurely with code {_archive.ExitCode}\nSTDERR:\n{stderr}"); + } + + try + { + var ctx = CreateClientContext(aeronDirectoryName); + // When an external aeronClient is supplied the probe AeronArchive uses it + // rather than creating its own embedded client. On disposal the probe only + // closes its internal pub/sub (fast) instead of closing the whole Aeron + // client, which on Windows causes the JVM conductor to spend >10s cleaning + // up file-mapped term buffers and would block new publication registrations. + if (aeronClient != null) + { + ctx.AeronClient(aeronClient); + } + // Keep the probe open for the lifetime of this EmbeddedArchive instance. + // Disposing it immediately creates a window between REMOVE_PUBLICATION and + // the test's own ADD_PUBLICATION where the archive sees no subscriber and + // won't send Status Messages, causing AWAIT_PUBLICATION_CONNECTED timeouts. + _probeClient = AeronArchive.Connect(ctx); + return; + } + catch (Exception e) + { + last = e; + Thread.Sleep(100); + } + } + + throw new TimeoutException($"aeron archive did not become ready within {StartupTimeoutMs}ms", last); + } + + private static DirectoryInfo GetSolutionDirectory(string currentPath) + { + var directory = new DirectoryInfo(currentPath ?? Directory.GetCurrentDirectory()); + while (directory != null && directory.GetFiles("*.sln").Length == 0) + { + directory = directory.Parent; + } + return directory; + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedArchiveSmokeTest.cs b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedArchiveSmokeTest.cs new file mode 100644 index 00000000..e7f1efcc --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedArchiveSmokeTest.cs @@ -0,0 +1,35 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using NUnit.Framework; + +namespace Adaptive.Archiver.IntegrationTests.Infrastructure +{ + [TestFixture] + [Category("Integration")] + public class EmbeddedArchiveSmokeTest + { + [Test, Timeout(60_000)] + public void CanStartDriverAndArchiveAndConnect() + { + using var driver = new EmbeddedMediaDriver(); + using var archive = new EmbeddedArchive(driver.AeronDirectoryName); + using var client = AeronArchive.Connect(archive.CreateClientContext(driver.AeronDirectoryName)); + + Assert.That(client, Is.Not.Null); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedMediaDriver.cs b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedMediaDriver.cs new file mode 100644 index 00000000..01abbf63 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/EmbeddedMediaDriver.cs @@ -0,0 +1,192 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using NUnit.Framework; +using AeronClient = Adaptive.Aeron.Aeron; + +namespace Adaptive.Archiver.IntegrationTests.Infrastructure +{ + /// + /// Launches a Java media driver process bound to a caller-supplied aeron directory. + /// Modeled on Adaptive.Aeron.Tests.EmbeddedMediaDriver, but accepts an explicit + /// aeron dir so multiple integration tests can run sequentially without colliding on + /// the process-wide default returned by Aeron.Context.GetAeronDirectoryName(). + /// + internal sealed class EmbeddedMediaDriver : IDisposable + { + private const int StartupTimeoutMs = 15_000; + private const int ShutdownTimeoutMs = 10_000; + + private readonly Process _driver; + private readonly string _aeronDir; + + public EmbeddedMediaDriver() + : this(Path.Combine(Path.GetTempPath(), "aeron-" + Guid.NewGuid().ToString("N"))) + { + } + + public EmbeddedMediaDriver(string aeronDirectoryName) + : this(aeronDirectoryName, withLossGenerators: false) + { + } + + public EmbeddedMediaDriver(string aeronDirectoryName, bool withLossGenerators) + : this(aeronDirectoryName, withLossGenerators, imageLivenessTimeout: "2s") + { + } + + public EmbeddedMediaDriver( + string aeronDirectoryName, + bool withLossGenerators, + string imageLivenessTimeout) + { + _aeronDir = aeronDirectoryName; + + if (Directory.Exists(_aeronDir)) + { + try { Directory.Delete(_aeronDir, recursive: true); } catch { } + } + + var rootDir = + GetSolutionDirectory(TestContext.CurrentContext.TestDirectory)?.Parent + ?? throw new FileNotFoundException("could not find root directory of project"); + var jarPath = Path.Combine(rootDir.FullName, "driver", "media-driver.jar"); + var lossGenJarPath = Path.Combine(rootDir.FullName, "driver", "aeron-test-loss-generators.jar"); + + var psi = new ProcessStartInfo + { + FileName = "java", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/jdk.internal.misc=ALL-UNNAMED"); + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/java.util.zip=ALL-UNNAMED"); + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/java.lang.reflect=ALL-UNNAMED"); + psi.ArgumentList.Add("--add-opens"); + psi.ArgumentList.Add("java.base/sun.nio.ch=ALL-UNNAMED"); + psi.ArgumentList.Add("-cp"); + psi.ArgumentList.Add(withLossGenerators ? jarPath + Path.PathSeparator + lossGenJarPath : jarPath); + psi.ArgumentList.Add($"-Daeron.dir={_aeronDir}"); + psi.ArgumentList.Add( + "-Daeron.driver.termination.validator=io.aeron.driver.DefaultAllowTerminationValidator"); + // Match Java's PersistentSubscriptionTest.setUp() driverCtxTpl which sets this so + // that a SourceLocation.LOCAL archive recording (which subscribes via spy prefix) + // makes the publication report IsConnected=true. Without this, MDC publications + // recorded with LOCAL source-location stay NOT_CONNECTED and Offer() loops forever. + psi.ArgumentList.Add("-Daeron.spies.simulate.connection=true"); + // Match Java PersistentSubscriptionTest driverCtxTpl. Short term buffers + short + // timeouts let tests exercise the live->replay fallback path: a faster consumer + // races past a slow persistent subscription within ~96KB of publishing, which + // triggers image unavailable and replay fallback in seconds rather than minutes. + psi.ArgumentList.Add("-Daeron.term.buffer.sparse.file=true"); + psi.ArgumentList.Add("-Daeron.term.buffer.length=65536"); + psi.ArgumentList.Add("-Daeron.ipc.term.buffer.length=65536"); + psi.ArgumentList.Add("-Daeron.dir.delete.on.shutdown=true"); + psi.ArgumentList.Add("-Daeron.image.liveness.timeout=" + imageLivenessTimeout); + psi.ArgumentList.Add("-Daeron.timer.interval=100ms"); + psi.ArgumentList.Add("-Daeron.untethered.window.limit.timeout=1s"); + psi.ArgumentList.Add("-Daeron.untethered.linger.timeout=1s"); + psi.ArgumentList.Add("-Daeron.publication.linger.timeout=1s"); + if (withLossGenerators) + { + psi.ArgumentList.Add( + "-Daeron.SendChannelEndpoint.supplier=" + + "io.adaptive.aeron.test.lossgen.LossGenSendChannelEndpointSupplier"); + psi.ArgumentList.Add( + "-Daeron.ReceiveChannelEndpoint.supplier=" + + "io.adaptive.aeron.test.lossgen.LossGenReceiveChannelEndpointSupplier"); + psi.ArgumentList.Add("io.adaptive.aeron.test.lossgen.LossGenMediaDriver"); + } + else + { + psi.ArgumentList.Add("io.aeron.driver.MediaDriver"); + } + + _driver = Process.Start(psi) ?? throw new InvalidOperationException("failed to start media driver"); + + WaitForDriverReady(); + } + + public string AeronDirectoryName => _aeronDir; + + public void Dispose() + { + EmbeddedArchive.ShutdownProcess(_driver, "EmbeddedMediaDriver"); + + bool exited = false; + try { exited = _driver.HasExited; } catch { } + + _driver.Dispose(); + + if (exited) + { + try + { + if (Directory.Exists(_aeronDir)) + { + Directory.Delete(_aeronDir, recursive: true); + } + } + catch + { + } + } + } + + private void WaitForDriverReady() + { + var deadline = DateTime.UtcNow.AddMilliseconds(StartupTimeoutMs); + Exception last = null; + while (DateTime.UtcNow < deadline) + { + if (_driver.HasExited) + { + throw new InvalidOperationException( + $"driver process exited prematurely with code {_driver.ExitCode}"); + } + try + { + using var aeron = AeronClient.Connect(new AeronClient.Context().AeronDirectoryName(_aeronDir)); + return; + } + catch (Exception e) + { + last = e; + Thread.Sleep(50); + } + } + throw new TimeoutException($"media driver did not become ready within {StartupTimeoutMs}ms", last); + } + + private static DirectoryInfo GetSolutionDirectory(string currentPath) + { + var directory = new DirectoryInfo(currentPath ?? Directory.GetCurrentDirectory()); + while (directory != null && directory.GetFiles("*.sln").Length == 0) + { + directory = directory.Parent; + } + return directory; + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Infrastructure/LossGenController.cs b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/LossGenController.cs new file mode 100644 index 00000000..c1616498 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/LossGenController.cs @@ -0,0 +1,217 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using Adaptive.Aeron; +using Adaptive.Agrona.Concurrent; +using AeronClient = Adaptive.Aeron.Aeron; + +namespace Adaptive.Archiver.IntegrationTests.Infrastructure +{ + /// + /// Publishes binary control commands to the LossGenControlAgent running in the driver + /// JVM. Opcodes and payload layout must mirror + /// io.adaptive.aeron.test.lossgen.LossGenControlAgent. + /// + internal sealed class LossGenController : IDisposable + { + private const string ControlChannel = "aeron:ipc?session-id=99999"; + private const int ControlStreamId = 10001; + + private const byte CmdFrameDataEnable = 0x01; + private const byte CmdFrameDataDisable = 0x02; + private const byte CmdStreamIdEnable = 0x03; + private const byte CmdStreamIdDisable = 0x04; + private const byte CmdStreamIdFrameDataEnable = 0x05; + private const byte CmdStreamIdFrameDataDisable = 0x06; + private const byte CmdDataInRangeSetTarget = 0x07; + private const byte CmdDataInRangeEnable = 0x08; + private const byte CmdDataInRangeDisable = 0x09; + private const byte CmdSetupAtPositionSetTarget = 0x0A; + private const byte CmdSetupAtPositionEnable = 0x0B; + private const byte CmdSetupAtPositionDisable = 0x0C; + + private const byte PredAlwaysTrue = 0x00; + private const byte PredRandomFraction = 0x01; + private const byte PredPayloadEqualsSticky = 0x02; + + private readonly Publication _publication; + // 4KB scratch: enough for sticky-match payloads up to ~4KB (current tests use 1376). + private readonly UnsafeBuffer _scratch = new UnsafeBuffer(new byte[4096]); + + public LossGenController(AeronClient aeron) + { + _publication = aeron.AddPublication(ControlChannel, ControlStreamId); + Tests.Await(() => _publication.IsConnected); + } + + public void EnableFrameDataAlwaysDrop() + { + _scratch.PutByte(0, CmdFrameDataEnable); + _scratch.PutByte(1, PredAlwaysTrue); + Offer(2); + } + + public void EnableFrameDataRandom(double fraction) + { + _scratch.PutByte(0, CmdFrameDataEnable); + _scratch.PutByte(1, PredRandomFraction); + _scratch.PutDouble(2, fraction); + Offer(10); + } + + /// + /// Enables the frame-data loss generator with a sticky-match predicate: drops everything + /// from the first frame whose payload matches the target onward. + /// + public void EnableFrameDataPayloadSticky(byte[] match) + { + // 1 cmd + 1 pred + 4 length + match.Length bytes + var required = 6 + match.Length; + if (_scratch.Capacity < required) + { + throw new InvalidOperationException( + "sticky-match payload too large; bump scratch buffer"); + } + _scratch.PutByte(0, CmdFrameDataEnable); + _scratch.PutByte(1, PredPayloadEqualsSticky); + _scratch.PutInt(2, match.Length); + _scratch.PutBytes(6, match); + Offer(required); + } + + public void DisableFrameData() + { + _scratch.PutByte(0, CmdFrameDataDisable); + Offer(1); + } + + public void EnableStreamId(int streamId) + { + _scratch.PutByte(0, CmdStreamIdEnable); + _scratch.PutInt(1, streamId); + Offer(5); + } + + public void DisableStreamId() + { + _scratch.PutByte(0, CmdStreamIdDisable); + Offer(1); + } + + public void EnableStreamIdFrameDataRandom(int streamId, double fraction) + { + _scratch.PutByte(0, CmdStreamIdFrameDataEnable); + _scratch.PutInt(1, streamId); + _scratch.PutByte(5, PredRandomFraction); + _scratch.PutDouble(6, fraction); + Offer(14); + } + + /// + /// Enables the stream-id frame-data loss generator with a sticky-match predicate: + /// it drops everything from the first frame whose payload matches the target onward. + /// + public void EnableStreamIdFrameDataPayloadSticky(int streamId, byte[] match) + { + // 1 cmd + 4 streamId + 1 pred + 4 length + match.Length bytes + var required = 10 + match.Length; + if (_scratch.Capacity < required) + { + throw new InvalidOperationException( + "sticky-match payload too large; bump scratch buffer"); + } + _scratch.PutByte(0, CmdStreamIdFrameDataEnable); + _scratch.PutInt(1, streamId); + _scratch.PutByte(5, PredPayloadEqualsSticky); + _scratch.PutInt(6, match.Length); + _scratch.PutBytes(10, match); + Offer(required); + } + + public void DisableStreamIdFrameData() + { + _scratch.PutByte(0, CmdStreamIdFrameDataDisable); + Offer(1); + } + + public void SetDataInRangeTarget(int streamId, int activeTermId, int min, int max) + { + _scratch.PutByte(0, CmdDataInRangeSetTarget); + _scratch.PutInt(1, streamId); + _scratch.PutInt(5, activeTermId); + _scratch.PutInt(9, min); + _scratch.PutInt(13, max); + Offer(17); + } + + public void EnableDataInRange() + { + _scratch.PutByte(0, CmdDataInRangeEnable); + Offer(1); + } + + public void DisableDataInRange() + { + _scratch.PutByte(0, CmdDataInRangeDisable); + Offer(1); + } + + public void SetSetupAtPositionTarget(int streamId, int initialTermId, int activeTermId, int termOffset) + { + _scratch.PutByte(0, CmdSetupAtPositionSetTarget); + _scratch.PutInt(1, streamId); + _scratch.PutInt(5, initialTermId); + _scratch.PutInt(9, activeTermId); + _scratch.PutInt(13, termOffset); + Offer(17); + } + + public void EnableSetupAtPosition() + { + _scratch.PutByte(0, CmdSetupAtPositionEnable); + Offer(1); + } + + public void DisableSetupAtPosition() + { + _scratch.PutByte(0, CmdSetupAtPositionDisable); + Offer(1); + } + + private void Offer(int length) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (_publication.Offer(_scratch, 0, length) < 0) + { + if (DateTime.UtcNow > deadline) + { + throw new InvalidOperationException("LossGenController: offer timed out"); + } + System.Threading.Thread.Sleep(1); + } + // The agent on the driver side polls on a 1ms cadence. Sleep briefly so the in-flight + // command has propagated before the test takes its next action — without this, tests + // that enable loss and immediately publish race against the IPC delivery window. + System.Threading.Thread.Sleep(50); + } + + public void Dispose() + { + _publication.Dispose(); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Infrastructure/TestContexts.cs b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/TestContexts.cs new file mode 100644 index 00000000..3373e643 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/TestContexts.cs @@ -0,0 +1,41 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Adaptive.Archiver.IntegrationTests.Infrastructure +{ + internal static class TestContexts + { + public const string LocalhostControlRequestChannel = "aeron:udp?endpoint=localhost:8010"; + public const string LocalhostControlResponseChannel = "aeron:udp?endpoint=localhost:0"; + public const string LocalhostReplicationChannel = "aeron:udp?endpoint=localhost:0"; + + public const string IpcChannel = "aeron:ipc"; + public const string MulticastChannel = "aeron:udp?endpoint=224.20.30.39:14456|interface=localhost"; + public const string EphemeralReplayChannel = "aeron:udp?endpoint=localhost:0"; + + // MDC and unicast channels with non-ephemeral ports must NOT be constants — two tests + // sharing the same hard-coded port would collide on the UDP bind under fast teardown. + // See PersistentSubscriptionTest.MdcSubscriptionChannel / MdcPublicationChannel for the + // per-test allocation pattern. + + public const int StreamId = 1000; + + public static AeronArchive.Context LocalhostAeronArchive() => + new AeronArchive.Context() + .ControlRequestChannel(LocalhostControlRequestChannel) + .ControlResponseChannel(LocalhostControlResponseChannel); + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/Infrastructure/Tests.cs b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/Tests.cs new file mode 100644 index 00000000..51d45390 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/Infrastructure/Tests.cs @@ -0,0 +1,101 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Threading; +using Adaptive.Agrona.Concurrent.Status; +using NUnit.Framework; + +namespace Adaptive.Archiver.IntegrationTests.Infrastructure +{ + /// + /// .NET equivalent of Java's io.aeron.test.Tests helpers. Provides + /// the await/executeUntil idiom used throughout the system tests. + /// + internal static class Tests + { + private const int DefaultPollIntervalMs = 1; + private const int DefaultTimeoutMs = 10_000; + + public static void ExecuteUntil(Func condition, Action action, int timeoutMs = DefaultTimeoutMs) + { + ExecuteUntil(condition, action, () => "condition was not met", timeoutMs); + } + + public static void ExecuteUntil( + Func condition, + Action action, + Func message, + int timeoutMs = DefaultTimeoutMs) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (!condition()) + { + if (DateTime.UtcNow > deadline) + { + Assert.Fail("timed out after " + timeoutMs + "ms: " + message()); + } + + action(); + Thread.Sleep(DefaultPollIntervalMs); + } + } + + public static void Await(Func condition, int timeoutMs = DefaultTimeoutMs) + { + ExecuteUntil(condition, () => { }, timeoutMs); + } + + public static int AwaitRecordingCounterId(CountersReader countersReader, int sessionId, long archiveId) + { + int counterId; + var deadline = DateTime.UtcNow.AddMilliseconds(DefaultTimeoutMs); + while ((counterId = Adaptive.Archiver.RecordingPos.FindCounterIdBySession( + countersReader, sessionId, archiveId)) == CountersReader.NULL_COUNTER_ID) + { + if (DateTime.UtcNow > deadline) + { + Assert.Fail("timed out waiting for recording counter for sessionId=" + sessionId); + } + Thread.Sleep(DefaultPollIntervalMs); + } + return counterId; + } + + public static void AwaitPosition(CountersReader countersReader, int counterId, long position) + { + var deadline = DateTime.UtcNow.AddMilliseconds(DefaultTimeoutMs); + while (countersReader.GetCounterValue(counterId) < position) + { + if (DateTime.UtcNow > deadline) + { + Assert.Fail($"timed out waiting for counter {counterId} to reach position {position}"); + } + Thread.Sleep(DefaultPollIntervalMs); + } + } + + public static void YieldingIdle(string reason) + { + Thread.Yield(); + } + + public static void AwaitConnected(Adaptive.Aeron.Subscription subscription, int timeoutMs = DefaultTimeoutMs) + { + Await(() => subscription.IsConnected, timeoutMs); + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/PersistentSubscriptionTest.cs b/src/Adaptive.Archiver.IntegrationTests/PersistentSubscriptionTest.cs new file mode 100644 index 00000000..7255ece3 --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/PersistentSubscriptionTest.cs @@ -0,0 +1,2961 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using Adaptive.Aeron.LogBuffer; +using Adaptive.Archiver.IntegrationTests.Helpers; +using Adaptive.Archiver.IntegrationTests.Infrastructure; +using NUnit.Framework; +using AeronClient = Adaptive.Aeron.Aeron; + +namespace Adaptive.Archiver.IntegrationTests +{ + /// + /// Common SetUp / TearDown for PersistentSubscription system tests. Mirrors the + /// abstract base in aeron-system-tests/.../PersistentSubscriptionTest.java: + /// concrete subclasses pick controlled vs uncontrolled poll semantics. + /// + [Category("Integration")] + internal abstract class PersistentSubscriptionTest + { + protected const int TermLength = LogBufferDescriptor.TERM_MIN_LENGTH; + protected const int OneKbMessageSize = 1024 - Adaptive.Aeron.Protocol.DataHeaderFlyweight.HEADER_LENGTH; + + private static readonly System.Random Random = new(); + + protected EmbeddedMediaDriver Driver; + protected EmbeddedArchive Archive; + protected AeronClient Aeron; + protected AeronArchive AeronArchive; + protected AeronArchive.Context AeronArchiveCtxTpl; + protected PersistentSubscription.Context PersistentSubscriptionCtx; + protected PersistentSubscriptionListener Listener; + protected readonly List Closeables = new(); + + // MDC control port — picked per test in SetUp. Two tests sharing this port would + // collide on the UDP bind exactly the way the archive control port used to. + protected int MdcControlPort; + protected string MdcSubscriptionChannel => $"aeron:udp?control=localhost:{MdcControlPort}"; + protected string MdcPublicationChannel => + $"aeron:udp?control=localhost:{MdcControlPort}|control-mode=dynamic|fc=max"; + + [SetUp] + public virtual void SetUp() + { + lock (Random) { MdcControlPort = Random.Next(25_000, 30_000); } + + Driver = new EmbeddedMediaDriver(); + Aeron = AeronClient.Connect(new AeronClient.Context().AeronDirectoryName(Driver.AeronDirectoryName)); + Archive = new EmbeddedArchive(Driver.AeronDirectoryName, aeronClient: Aeron); + + AeronArchiveCtxTpl = Archive + .CreateClientContext(Driver.AeronDirectoryName) + .AeronClient(Aeron); + + AeronArchive = AeronArchive.Connect(CloneArchiveCtx()); + + Listener = new PersistentSubscriptionListener(); + PersistentSubscriptionCtx = new PersistentSubscription.Context() + .Aeron(Aeron) + .RecordingId(13) + .StartPosition(0) + .LiveChannel(TestContexts.IpcChannel) + .LiveStreamId(TestContexts.StreamId) + .ReplayChannel(TestContexts.EphemeralReplayChannel) + .ReplayStreamId(-5) + .Listener(Listener) + .AeronArchiveContext(CloneArchiveCtx()); + } + + [TearDown] + public virtual void TearDown() + { + foreach (var c in System.Linq.Enumerable.Reverse(Closeables)) + { + DisposeWithTimeout(c, 3_000, "Closeable"); + } + Closeables.Clear(); + + DisposeWithTimeout(AeronArchive, 3_000, "AeronArchive"); + DisposeWithTimeout(Aeron, 3_000, "Aeron"); + + try + { + Archive?.Dispose(); + } + catch + { + // Ignored + } + + try + { + Driver?.Dispose(); + } + catch + { + // Ignored + } + } + + private static void DisposeWithTimeout(IDisposable target, int timeoutMs, string name) + { + if (target == null) + { + return; + } + var task = System.Threading.Tasks.Task.Run(() => + { + try + { + target.Dispose(); + } + catch + { + // Ignored + } + }); + if (!task.Wait(timeoutMs)) + { + TestContext.Progress.WriteLine( + $"TearDown: {name}.Dispose did not return within {timeoutMs}ms; " + + "abandoning to JVM kill below."); + } + } + + /// + /// Concrete subclasses pick controlled vs uncontrolled poll. + /// + protected abstract int Poll(PersistentSubscription subscription, IFragmentHandler handler, int fragmentLimit); + + protected AeronArchive.Context CloneArchiveCtx() => + Archive + .CreateClientContext(Driver.AeronDirectoryName) + .AeronClient(Aeron); + + protected static IList GenerateFixedPayloads(int count, int size) + { + var payloads = new List(count); + for (var i = 0; i < count; i++) + { + var payload = new byte[size]; + lock (Random) + { + Random.NextBytes(payload); + } + payloads.Add(payload); + } + return payloads; + } + + protected static IList GenerateRandomPayloads(int count) + { + var payloads = new List(count); + for (var i = 0; i < count; i++) + { + int length; + lock (Random) + { + length = Random.Next(2048); + } + var payload = new byte[length]; + lock (Random) + { + Random.NextBytes(payload); + } + payloads.Add(payload); + } + return payloads; + } + + protected static void AssertPayloads(IList received, params IList[] expectedBatches) + { + var expected = new List(); + foreach (var batch in expectedBatches) { expected.AddRange(batch); } + + Assert.That(received.Count, Is.EqualTo(expected.Count), + "payload count mismatch: received {0} vs expected {1}", received.Count, expected.Count); + + for (var i = 0; i < expected.Count; i++) + { + Assert.That(received[i], Is.EqualTo(expected[i]), "payload mismatch at index " + i); + } + } + + [Test, Timeout(15_000)] + public void ShouldErrorIfRecordingDoesNotExist() + { + const int nonExistentRecordingId = 13; + PersistentSubscriptionCtx.RecordingId(nonExistentRecordingId); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil(() => persistentSubscription.HasFailed, () => Poll(persistentSubscription, null, 1)); + + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That( + ((PersistentSubscriptionException)Listener.LastException).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.RECORDING_NOT_FOUND)); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [Test, Timeout(15_000)] + public void ShouldNotRequireEventListener() + { + PersistentSubscriptionCtx.Listener(null); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil(() => persistentSubscription.HasFailed, () => Poll(persistentSubscription, null, 1)); + } + + [Test, Timeout(15_000)] + public void ShouldErrorIfRecordingStreamDoesNotMatchLiveStream() + { + const int liveStreamId = 1001; + + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveStreamId(liveStreamId); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil(() => persistentSubscription.HasFailed, () => Poll(persistentSubscription, null, 1)); + + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That( + ((PersistentSubscriptionException)Listener.LastException).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.STREAM_ID_MISMATCH)); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [Test, Timeout(15_000)] + public void ShouldErrorIfStartPositionIsBeforeRecordingStartPosition() + { + var channel = new Adaptive.Aeron.ChannelUriStringBuilder() + .Media("ipc") + .InitialPosition(1024, 0, TermLength) + .Build(); + + var persistentPublication = PersistentPublication.Create(AeronArchive, channel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + const int startPosition = 0; + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(startPosition); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil(() => persistentSubscription.HasFailed, () => Poll(persistentSubscription, null, 1)); + + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That( + ((PersistentSubscriptionException)Listener.LastException).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.INVALID_START_POSITION)); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [Test, Timeout(15_000)] + public void ShouldErrorIfStartPositionIsAfterStopPosition() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + persistentPublication.Persist(GenerateFixedPayloads(1, OneKbMessageSize)); + + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + + var startPosition = stopPosition * 2; + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(startPosition); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil(() => persistentSubscription.HasFailed, () => Poll(persistentSubscription, null, 1)); + + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That( + ((PersistentSubscriptionException)Listener.LastException).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.INVALID_START_POSITION)); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [Test, Timeout(15_000)] + public void ShouldReplayFromSpecificMidRecordingPosition() + { + var channel = new Adaptive.Aeron.ChannelUriStringBuilder().Media("ipc").Build(); + var persistentPublication = PersistentPublication.Create(AeronArchive, channel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var firstBatch = GenerateFixedPayloads(4, OneKbMessageSize); + persistentPublication.Persist(firstBatch); + var secondBatch = GenerateFixedPayloads(2, OneKbMessageSize); + persistentPublication.Persist(secondBatch); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(4096); + + var fragmentHandler = new BufferingFragmentHandler(); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(secondBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, secondBatch); + } + + [Test, Timeout(15_000)] + public void CanJoinALiveStreamAtTheBeginning() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(fragmentHandler.ReceivedPayloads, Is.Empty); + + var messages = GenerateRandomPayloads(3); + persistentPublication.Persist(messages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + } + + [Test, Timeout(15_000)] + public void ShouldNotReplayOldMessagesWhenStartingFromLive() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var oldMessages = GenerateRandomPayloads(5); + persistentPublication.Persist(oldMessages); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(fragmentHandler.ReceivedPayloads, Is.Empty); + + var newMessages = GenerateRandomPayloads(3); + persistentPublication.Persist(newMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(newMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, newMessages); + } + + [Test, Timeout(15_000)] + public void ShouldErrorWhenStartPositionDoesNotAlignWithFrame() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + persistentPublication.Persist(GenerateFixedPayloads(1, OneKbMessageSize)); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(OneKbMessageSize - 32) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [TestCase(0L)] + [TestCase(1024L)] + [Timeout(15_000)] + public void ShouldReplayFromRecordingStartPositionWhenStartingFromStart(long recordingStartPosition) + { + var channel = new Adaptive.Aeron.ChannelUriStringBuilder() + .Media("ipc") + .InitialPosition(recordingStartPosition, 0, TermLength) + .Build(); + + var persistentPublication = PersistentPublication.Create(AeronArchive, channel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var messages = GenerateFixedPayloads(3, OneKbMessageSize); + persistentPublication.Persist(messages); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_START); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + } + + [Test, Timeout(20_000)] + public void ShouldStartFromLiveWhenThereIsNoDataToReplay() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10), 18_000); + + Assert.That(fragmentHandler.ReceivedPayloads, Is.Empty); + + var messages = GenerateRandomPayloads(5); + persistentPublication.Persist(messages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10), 18_000); + } + + [Test, Timeout(15_000)] + public void CanStartFromLiveWhenRecordingHasStopped() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var firstBatch = GenerateRandomPayloads(1); + var secondBatch = GenerateRandomPayloads(1); + persistentPublication.Persist(firstBatch); + + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + persistentPublication.Publish(secondBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(secondBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 1)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, secondBatch); + } + + [Test, Timeout(15_000)] + public void CanStartAtRecordingStopPositionWhenLiveHasNotAdvanced() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + persistentPublication.Persist(GenerateFixedPayloads(1, OneKbMessageSize)); + + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(stopPosition); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + var liveMessages = GenerateRandomPayloads(3); + persistentPublication.Publish(liveMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(liveMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 1)); + AssertPayloads(fragmentHandler.ReceivedPayloads, liveMessages); + Assert.That(Listener.ErrorCount, Is.EqualTo(0)); + } + + [Test, Timeout(15_000)] + public void ShouldStartFromStoppedRecordingAndJoinLiveWhenLiveHasNotAdvanced() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var oldMessages = GenerateFixedPayloads(8, OneKbMessageSize); + persistentPublication.Persist(oldMessages); + + persistentPublication.Stop(); + + PersistentSubscriptionCtx + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(persistentPublication.RecordingId); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(oldMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + AssertPayloads(fragmentHandler.ReceivedPayloads, oldMessages); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var newMessages = GenerateFixedPayloads(16, OneKbMessageSize); + persistentPublication.Publish(newMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(oldMessages.Count + newMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsLive, Is.True); + Assert.That(persistentSubscription.IsReplaying, Is.False); + } + + [Test, Timeout(30_000)] + public void ShouldStartFromStoppedRecordingAndErrorWhenLiveHasAdvanced() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var recordedMessages = GenerateFixedPayloads(8, OneKbMessageSize); + persistentPublication.Persist(recordedMessages); + + persistentPublication.Stop(); + + // Add another subscriber so the publication's live position can advance. + using (var subscriber2 = Aeron.AddSubscription(MdcSubscriptionChannel, TestContexts.StreamId)) + { + var afterRecording = GenerateFixedPayloads(1, OneKbMessageSize); + persistentPublication.Publish(afterRecording); + + var subscriber2Handler = new BufferingFragmentHandler(); + Tests.ExecuteUntil( + () => subscriber2Handler.HasReceivedPayloads(afterRecording.Count), + () => subscriber2.Poll(subscriber2Handler, 10)); + } + + PersistentSubscriptionCtx + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(persistentPublication.RecordingId); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create( + PersistentSubscriptionCtx.Clone() + .AeronArchiveContext(CloneArchiveCtx().MessageTimeoutNs(15_000_000_000L))); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(recordedMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsReplaying, Is.True); + + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(recordedMessages.Count)); + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That( + ((PersistentSubscriptionException)Listener.LastException).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.INVALID_START_POSITION)); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [Test, Timeout(15_000)] + public void ShouldFailIfLiveStreamPositionGoesBackwards() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var payloads = GenerateFixedPayloads(2, 32); + persistentPublication.Persist(payloads); + + PersistentSubscriptionCtx.RecordingId(persistentPublication.RecordingId); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + persistentPublication.Dispose(); + Closeables.Remove(persistentPublication); + + var replacement = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(replacement); + + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + // Java asserts the full "ERROR - live stream joined..." string, where "ERROR - " is + // prepended by Java's AeronException base-class constructor. The .NET AeronException + // does NOT prepend the category name (see follow-up task) -- a separate base-class + // fix is needed before this can match Java byte-for-byte. Test logical content for now. + Assert.That(persistentSubscription.FailureReason?.Message, + Does.Contain("live stream joined at position 0 which is earlier than last seen position 128")); + } + + [Test, Timeout(30_000)] + public void ShouldStayOnReplayWhenLiveCannotConnect() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var messages = GenerateRandomPayloads(5); + persistentPublication.Persist(messages); + + // Wrong control endpoint for the live channel -- the live subscription will never connect. + const string incorrectLiveChannel = "aeron:udp?control=localhost:49582|control-mode=dynamic"; + + // 5 s: long enough for the subprocess JVM archive handshake on slow Windows CI (which can + // exceed 500 ms), yet short enough for "No image became available" to fire within the timeout. + var archiveCtx = CloneArchiveCtx().MessageTimeoutNs(5_000_000_000L); + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_START) + .LiveChannel(incorrectLiveChannel) + .AeronArchiveContext(archiveCtx); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(5), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsReplaying, Is.True); + + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LastException?.Message, + Does.Contain("No image became available on the live subscription")); + Assert.That(persistentSubscription.HasFailed, Is.False); + Assert.That(persistentSubscription.FailureReason, Is.Null); + Assert.That(persistentSubscription.IsReplaying, Is.True); + + var moreMessages = GenerateRandomPayloads(3); + persistentPublication.Persist(moreMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count + moreMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsReplaying, Is.True); + AssertPayloads(fragmentHandler.ReceivedPayloads, messages, moreMessages); + } + + [Test, Timeout(15_000)] + public void FallbackFromLiveFailsWhenRecordingStoppedBeforeLivePosition() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + persistentPublication.Persist(GenerateRandomPayloads(1)); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + persistentPublication.Stop(); + + // Messages published past the now-frozen recording stop position. + var liveOnly = GenerateRandomPayloads(3); + persistentPublication.Publish(liveOnly); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(liveOnly.Count), + () => Poll(persistentSubscription, fragmentHandler, 1)); + + persistentPublication.ClosePublicationOnly(); + + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + Assert.That( + ((PersistentSubscriptionException)persistentSubscription.FailureReason).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.INVALID_START_POSITION)); + } + + [Test, Timeout(20_000)] + public void CannotFallbackToReplayWhenRecordingHasStoppedAtAnEarlierPosition() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + using var fastDriver = new EmbeddedMediaDriver(); + using var fastAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(fastDriver.AeronDirectoryName)); + using var fastSubscription = fastAeron.AddSubscription(MdcSubscriptionChannel, TestContexts.StreamId); + var fastHandler = new CountingFragmentHandler(); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + // Consume a batch of messages on live. + var firstBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(firstBatch); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount), + () => fastSubscription.Poll(fastHandler, 10)); + + // Stop the recording. + AeronArchive.StopRecording(persistentPublication.ExclusivePub); + + // Publish more messages past the now-frozen recording stop position. + var secondBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Publish(secondBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(firstBatch.Count + secondBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + // End the live image via EOS rather than waiting for a flow-control timeout. + persistentPublication.ClosePublicationOnly(); + + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + Assert.That( + ((PersistentSubscriptionException)Listener.LastException).ReasonValue, + Is.EqualTo(PersistentSubscriptionException.Reason.INVALID_START_POSITION)); + } + + [Test, Timeout(20_000)] + public void CannotFallbackToReplayWhenRecordingHasBeenRemoved() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + using var fastDriver = new EmbeddedMediaDriver(); + using var fastAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(fastDriver.AeronDirectoryName)); + using var fastConsumer = fastAeron.AddSubscription(MdcSubscriptionChannel, TestContexts.StreamId); + var fastHandler = new CountingFragmentHandler(); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + Tests.AwaitConnected(fastConsumer); + + var firstBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(firstBatch); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount), + () => fastConsumer.Poll(fastHandler, 10)); + + // Remove the recording entirely. + AeronArchive.StopRecording(persistentPublication.ExclusivePub); + AeronArchive.PurgeRecording(persistentPublication.RecordingId); + + // Let the fast consumer race ahead of the persistent subscription so PS falls off live. + var secondBatch = new List(); + for (var i = 0; i < 3; i++) + { + var batch = GenerateFixedPayloads(32, OneKbMessageSize); + persistentPublication.Publish(batch); + secondBatch.AddRange(batch); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(secondBatch.Count), + () => fastConsumer.Poll(fastHandler, 10)); + } + + // PS cannot fall back -- the recording is gone. + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + Assert.That(Listener.LastException.Message, Does.Contain("unknown recording id:")); + } + + [Test, Timeout(15_000)] + public void ShouldErrorIfStartPositionIsAfterRecordingLivePosition() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + persistentPublication.Persist(GenerateFixedPayloads(1, OneKbMessageSize)); + + var recordedPosition = persistentPublication.Position; + Assert.That(recordedPosition, Is.GreaterThan(0)); + + var startPosition = recordedPosition * 2; + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(startPosition); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + Tests.ExecuteUntil(() => persistentSubscription.HasFailed, () => Poll(persistentSubscription, null, 1)); + + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That(persistentSubscription.FailureReason, Is.SameAs(Listener.LastException)); + } + + [Test, Timeout(20_000)] + public void ShouldDropFromLiveBackToReplayThenJoinLiveAgain() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var firstBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(firstBatch); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(0)); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(1), + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(persistentSubscription.IsReplaying, Is.True); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + Assert.That(fragmentHandler.HasReceivedPayloads(firstBatch.Count), Is.True); + + var secondBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(secondBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(firstBatch.Count + secondBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(persistentSubscription.IsLive, Is.True); + + using var fastDriver = new EmbeddedMediaDriver(); + using var fastAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(fastDriver.AeronDirectoryName)); + using var fastConsumer = fastAeron.AddSubscription(MdcSubscriptionChannel, TestContexts.StreamId); + var fastHandler = new CountingFragmentHandler(); + Tests.AwaitConnected(fastConsumer); + + var thirdBatch = new List(); + for (var i = 0; i < 3; i++) + { + var batch = GenerateFixedPayloads(32, OneKbMessageSize); + persistentPublication.Publish(batch); + thirdBatch.AddRange(batch); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(thirdBatch.Count), + () => fastConsumer.Poll(fastHandler, 10)); + } + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(1)); + + var fourthBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(fourthBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount) + && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(2)); + AssertPayloads(fragmentHandler.ReceivedPayloads, firstBatch, secondBatch, thirdBatch, fourthBatch); + } + + [Test, Timeout(20_000)] + public void CanFallbackToReplayAfterStartingFromLive() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + // These are persisted BEFORE the PS starts -- since PS uses FROM_LIVE, it must + // NOT receive them. This catches accidental replay of pre-live history. + var firstBatch = GenerateRandomPayloads(2); + persistentPublication.Persist(firstBatch); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(persistentSubscription.IsReplaying, Is.False); + + var secondBatch = GenerateRandomPayloads(5); + persistentPublication.Persist(secondBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(secondBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + using var fastDriver = new EmbeddedMediaDriver(); + using var fastAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(fastDriver.AeronDirectoryName)); + using var fastConsumer = fastAeron.AddSubscription(MdcSubscriptionChannel, TestContexts.StreamId); + var fastHandler = new CountingFragmentHandler(); + Tests.AwaitConnected(fastConsumer); + + var thirdBatch = new List(); + for (var i = 0; i < 3; i++) + { + var batch = GenerateFixedPayloads(32, OneKbMessageSize); + persistentPublication.Publish(batch); + thirdBatch.AddRange(batch); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(thirdBatch.Count), + () => fastConsumer.Poll(fastHandler, 10)); + } + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(1)); + + var fourthBatch = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(fourthBatch); + + var expectedCount = secondBatch.Count + thirdBatch.Count + fourthBatch.Count; + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(expectedCount) && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(2)); + AssertPayloads(fragmentHandler.ReceivedPayloads, secondBatch, thirdBatch, fourthBatch); + } + + + [Test, Timeout(10_000)] + public void ShouldHandleReplayBeingAheadOfLive() + { + int controlPort; + lock (Random) { controlPort = Random.Next(30_000, 35_000); } + var pubChannel = $"aeron:udp?control=localhost:{controlPort}|control-mode=dynamic|fc=min"; + var subChannel = $"aeron:udp?control=localhost:{controlPort}|rcv-wnd=4k"; + + var persistentPublication = PersistentPublication.Create( + AeronArchive, pubChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + using var slowDriver = new EmbeddedMediaDriver(); + using var aeron2 = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(slowDriver.AeronDirectoryName)); + using var slowConsumer = aeron2.AddSubscription(subChannel, TestContexts.StreamId); + Tests.AwaitConnected(slowConsumer); + + persistentPublication.Persist(GenerateFixedPayloads(32, OneKbMessageSize)); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(subChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(32), + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(persistentSubscription.IsReplaying, Is.True); + + Tests.ExecuteUntil( + () => persistentPublication.ReceiverCount() == 2, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + // Consuming on the slow consumer lets the sender push past the initial 4k window. + Tests.ExecuteUntil(() => persistentSubscription.IsLive, + () => + { + Poll(persistentSubscription, fragmentHandler, 10); + slowConsumer.Poll((b, o, l, h) => { }, 10); + }); + + Assert.That(persistentSubscription.JoinDifference, Is.LessThanOrEqualTo(0)); + } + + [Test, Timeout(60_000)] + public void ShouldReceiveAllMessagesWithModerateReplayLoss() + { + RunReplayLossTest(100, 0.3); + } + + [Test, Timeout(90_000)] + public void ShouldReceiveAllMessagesWithHeavyReplayLoss() + { + RunReplayLossTest(50, 0.8); + } + + private void RunReplayLossTest(int messageCount, double dropRate) + { + // PS lives on a separate driver whose receive endpoint runs through the loss generator. + // The archive (on the main driver) keeps a complete recording; loss only affects what + // arrives at PS, so NAK/retransmit must restore everything. + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + var messages = GenerateFixedPayloads(messageCount, OneKbMessageSize); + persistentPublication.Persist(messages); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + lossController.EnableStreamIdFrameDataRandom( + PersistentSubscriptionCtx.ReplayStreamId(), dropRate); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count) && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10), timeoutMs: 50_000); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + lossController.DisableStreamIdFrameData(); + } + + [Test, Timeout(20_000)] + public void CanJoinLiveInTheMiddleOfAFragmentedMessage() + { + // Publisher lives on a second driver whose SEND endpoint drops post-match frames. + // PS lives on the main driver (no loss). Archive records REMOTE from the same MDC + // channel — but only the first half of the message reaches everyone before sticky + // drop kicks in. After loss is disabled, NAK/retransmit must deliver the rest. + using var pubDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var pubAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(pubDriver.AeronDirectoryName)); + using var lossController = new LossGenController(pubAeron); + + const int maxPayloadLength = 1408 - 32; + var firstHalfOfMessage = new byte[maxPayloadLength]; + Array.Fill(firstHalfOfMessage, (byte)1); + var secondHalfOfMessage = new byte[maxPayloadLength]; + Array.Fill(secondHalfOfMessage, (byte)2); + var largeMessage = new byte[firstHalfOfMessage.Length + secondHalfOfMessage.Length]; + Array.Copy(firstHalfOfMessage, 0, largeMessage, 0, firstHalfOfMessage.Length); + Array.Copy(secondHalfOfMessage, 0, largeMessage, + firstHalfOfMessage.Length, secondHalfOfMessage.Length); + + using var exclusivePublication = pubAeron.AddExclusivePublication( + MdcPublicationChannel, TestContexts.StreamId); + AeronArchive.StartRecording( + MdcPublicationChannel, TestContexts.StreamId, Codecs.SourceLocation.REMOTE); + Tests.Await(() => exclusivePublication.IsConnected); + + var persistentPublication = PersistentPublication.Create(AeronArchive, exclusivePublication); + Closeables.Add(persistentPublication); + + lossController.EnableFrameDataPayloadSticky(secondHalfOfMessage); + persistentPublication.Publish(new List { largeMessage }); + + PersistentSubscriptionCtx + .LiveChannel(MdcSubscriptionChannel) + .LiveStreamId(TestContexts.StreamId) + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_START); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(0)); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(0)); + + lossController.DisableFrameData(); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(1), + () => Poll(persistentSubscription, fragmentHandler, 1)); + AssertPayloads(fragmentHandler.ReceivedPayloads, new List { largeMessage }); + } + + [Test, Timeout(20_000)] + public void CanFallbackToReplayInTheMiddleOfAFragmentedMessage() + { + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var maxPayloadLength = persistentPublication.MaxPayloadLength; + var firstHalfOfMessage = new byte[maxPayloadLength]; + Array.Fill(firstHalfOfMessage, (byte)1); + var secondHalfOfMessage = new byte[maxPayloadLength]; + Array.Fill(secondHalfOfMessage, (byte)2); + var largeMessage = new byte[firstHalfOfMessage.Length + secondHalfOfMessage.Length]; + Array.Copy(firstHalfOfMessage, 0, largeMessage, 0, firstHalfOfMessage.Length); + Array.Copy(secondHalfOfMessage, 0, largeMessage, + firstHalfOfMessage.Length, secondHalfOfMessage.Length); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .LiveChannel(MdcSubscriptionChannel) + .LiveStreamId(TestContexts.StreamId) + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + // Sticky drop starting from the first frame whose payload matches the second half: + // PS receives the first fragment, then loses the rest of the stream. It must + // fall back to replay and refetch the complete message from the archive. + lossController.EnableStreamIdFrameDataPayloadSticky(TestContexts.StreamId, secondHalfOfMessage); + + persistentPublication.Publish(new List { largeMessage }); + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(0)); + + lossController.DisableStreamIdFrameData(); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + AssertPayloads(fragmentHandler.ReceivedPayloads, new List { largeMessage }); + } + + [Test, Timeout(20_000)] + public void CanSwitchFromReplayToLiveWhenLivePositionIsAheadOfReplayPosition() + { + // Long image-liveness timeout: stream-id loss freezes replay-channel DATA for the + // duration of the catchup phase. With the standard 2s timeout used by other tests, + // the replay image times out under system load → CleanUpLiveSubscription discards + // the eager-add live subscription → PS falls through to AWAIT_LIVE which sets + // joinDifference(0), defeating the REPLAY → ATTEMPT_SWITCH path this test verifies. + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true, + imageLivenessTimeout: "30s"); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(persistentPublication.RecordingId); + + var messagesToConsumeOnReplay = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(messagesToConsumeOnReplay); + var initialStopPosition = persistentPublication.Position; + + // Block publisher SETUPs at snd_pos=5KB. Publisher keeps retrying; every retry until + // we publish more carries this same tuple and is dropped, so the eager-add live image + // can't attach during the replay phase. + ArmSetupDropAtPosition(lossController, persistentPublication.RecordingId, initialStopPosition); + lossController.EnableSetupAtPosition(); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(1), + () => Poll(persistentSubscription, fragmentHandler, 1)); + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount), + () => + { + Poll(persistentSubscription, fragmentHandler, 1); + Assert.That(persistentSubscription.IsReplaying, Is.True); + }); + + // Stop replay from advancing. + lossController.EnableStreamId(PersistentSubscriptionCtx.ReplayStreamId()); + + // Send 2 more so live position advances ahead of replay. Publisher's next SETUP now + // carries the advanced snd_pos (no longer matches drop target) and is delivered, + // attaching the live image at 7KB while replay is frozen at 5KB by the streamId loss. + var messagesToConsumeAfterAddingLive = GenerateFixedPayloads(2, OneKbMessageSize); + persistentPublication.Publish(messagesToConsumeAfterAddingLive); + + Tests.ExecuteUntil( + () => persistentSubscription.JoinDifference != long.MinValue, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + lossController.DisableStreamId(); + lossController.DisableSetupAtPosition(); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount) + && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.JoinDifference, Is.EqualTo(2048)); + + var messagesToConsumeOnLive = GenerateFixedPayloads(2, OneKbMessageSize); + persistentPublication.Publish(messagesToConsumeOnLive); + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsLive, Is.True); + AssertPayloads( + fragmentHandler.ReceivedPayloads, + messagesToConsumeOnReplay, messagesToConsumeAfterAddingLive, messagesToConsumeOnLive); + } + + [Test, Timeout(60_000)] + public void ShouldReplayAndCatchUpWhenExtendedRecordingIsAheadOfLivePosition() + { + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + persistentPublication.Persist(GenerateFixedPayloads(1, OneKbMessageSize)); + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + var recordingId = persistentPublication.RecordingId; + + persistentPublication.ClosePublicationOnly(); + Tests.Await(() => !persistentPublication.PublicationCountersExist()); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(recordingId) + .StartPosition(stopPosition) + .LiveChannel(MdcSubscriptionChannel) + .AeronArchiveContext().MessageTimeoutNs(15_000_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + var observedReplaying = new[] { false }; + Action pollAndTrack = () => + { + Poll(persistentSubscription, fragmentHandler, 10); + if (persistentSubscription.IsReplaying) + { + observedReplaying[0] = true; + } + }; + + // Wait for one AWAIT_LIVE deadline breach. + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, pollAndTrack, 30_000); + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That(persistentSubscription.IsLive, Is.False); + Assert.That(observedReplaying[0], Is.False); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId, recordingId); + + // Publish first then wait for receipt — ensures the live image is attached before + // revoke, otherwise PS could be stuck in AWAIT_LIVE. + var firstBatch = GenerateFixedPayloads(1, OneKbMessageSize); + resumedPublication.Persist(firstBatch); + Tests.ExecuteUntil(() => fragmentHandler.HasReceivedPayloads(firstBatch.Count), pollAndTrack); + Tests.ExecuteUntil(() => persistentSubscription.IsLive, pollAndTrack); + + // Reset observedReplaying — only post-revoke catchup-refresh should flip it back true. + observedReplaying[0] = false; + + ArmDataDropFromPosition(lossController, recordingId, stopPosition + 1024L); + lossController.EnableDataInRange(); + + // Recording sees all 40; PS's live image sees none (dropped on PS's endpoint). + var catchupMessages = GenerateFixedPayloads(40, OneKbMessageSize); + resumedPublication.Persist(catchupMessages); + + // Revoke ends the live image; after EOS+REVOKED heartbeat, the dropped bytes are + // gone from the live channel and PS must replay them. + resumedPublication.ExclusivePub.Revoke(); + + var expected = new List(firstBatch); + expected.AddRange(catchupMessages); + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(expected.Count), pollAndTrack); + + lossController.DisableDataInRange(); + + AssertPayloads(fragmentHandler.ReceivedPayloads, expected); + Assert.That(observedReplaying[0], Is.True, + "PS did not transition through REPLAY/ATTEMPT_SWITCH after revoke"); + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + } + + private void ArmDataDropFromPosition(LossGenController controller, long recordingId, long fromPosition) + { + var descriptor = new ResumeDescriptorReader(); + AeronArchive.ListRecording(recordingId, descriptor); + var initialTermId = descriptor.InitialTermId; + var termBufferLength = descriptor.TermBufferLength; + var positionBitsToShift = LogBufferDescriptor.PositionBitsToShift(termBufferLength); + var targetActiveTermId = LogBufferDescriptor.ComputeTermIdFromPosition( + fromPosition, positionBitsToShift, initialTermId); + var targetTermOffsetMin = (int)(fromPosition & (termBufferLength - 1L)); + controller.SetDataInRangeTarget( + TestContexts.StreamId, targetActiveTermId, targetTermOffsetMin, termBufferLength); + } + + [Test, Timeout(30_000)] + public void ShouldRefreshAndReplayWhenLiveAheadOfStopPositionAfterResume() + { + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + persistentPublication.Persist(GenerateFixedPayloads(1, OneKbMessageSize)); + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + var recordingId = persistentPublication.RecordingId; + + ArmSetupDropAtPosition(lossController, recordingId, stopPosition); + + persistentPublication.ClosePublicationOnly(); + Tests.Await(() => !persistentPublication.PublicationCountersExist()); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(recordingId) + .StartPosition(stopPosition) + .LiveChannel(MdcSubscriptionChannel) + .AeronArchiveContext().MessageTimeoutNs(5_000_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + var observedReplaying = new[] { false }; + Action pollAndTrack = () => + { + Poll(persistentSubscription, fragmentHandler, 10); + if (persistentSubscription.IsReplaying) + { + observedReplaying[0] = true; + } + }; + + // Wait for one AWAIT_LIVE deadline breach. + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, pollAndTrack); + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + Assert.That( + Listener.LastException.Message, + Does.Contain("No image became available on the live subscription")); + Assert.That(persistentSubscription.IsLive, Is.False); + Assert.That(observedReplaying[0], Is.False); + + lossController.EnableSetupAtPosition(); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + + var postResumeMessages = GenerateFixedPayloads(4, OneKbMessageSize); + resumedPublication.Persist(postResumeMessages); + + Tests.ExecuteUntil(() => persistentSubscription.IsLive, pollAndTrack); + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(postResumeMessages.Count), pollAndTrack); + AssertPayloads(fragmentHandler.ReceivedPayloads, postResumeMessages); + + lossController.DisableSetupAtPosition(); + + Assert.That(observedReplaying[0], Is.True, + "PS did not transition through REPLAY/ATTEMPT_SWITCH; refresh path was not exercised"); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + Assert.That(Listener.ErrorCount, Is.EqualTo(1)); + } + + [TestCase(1), TestCase(10), Timeout(20_000)] + public void ShouldReplayExistingRecordingThenJoinLive(int fragmentLimit) + { + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var replayMessages = GenerateRandomPayloads(5); + persistentPublication.Persist(replayMessages); + var stopPosition = persistentPublication.Position; + + // Drop publisher's SETUP at its current snd_pos on PS's endpoint. Publisher is idle + // through the replay phase so snd_pos doesn't advance — every SETUP carries the same + // tuple and gets dropped until we disable the generator. + ArmSetupDropAtPosition(lossController, persistentPublication.RecordingId, stopPosition); + lossController.EnableSetupAtPosition(); + + AeronArchive.StopRecording(persistentPublication.ExclusivePub); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(persistentPublication.RecordingId); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, fragmentLimit)); + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(replayMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, fragmentLimit)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(0)); + + // Wait for PS to leave REPLAY/ATTEMPT_SWITCH — proves closed-image cleanup path ran. + Tests.ExecuteUntil( + () => !persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, fragmentLimit)); + + // Re-allow SETUPs; the next one carries the live image at snd_pos == stopPosition, + // matching PS's current position so AWAIT_LIVE → LIVE with joinDifference == 0. + lossController.DisableSetupAtPosition(); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, fragmentLimit)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(replayMessages.Count)); + + var liveMessages = GenerateRandomPayloads(15); + persistentPublication.Publish(liveMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(replayMessages.Count + liveMessages.Count), + () => + { + Poll(persistentSubscription, fragmentHandler, fragmentLimit); + Assert.That(persistentSubscription.IsLive, Is.True); + }); + + Assert.That(persistentSubscription.IsReplaying, Is.False); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + Assert.That(persistentSubscription.JoinDifference, Is.EqualTo(0)); + AssertPayloads(fragmentHandler.ReceivedPayloads, replayMessages, liveMessages); + } + + private void ArmSetupDropAtPosition(LossGenController controller, long recordingId, long position) + { + var descriptor = new ResumeDescriptorReader(); + AeronArchive.ListRecording(recordingId, descriptor); + var initialTermId = descriptor.InitialTermId; + var termBufferLength = descriptor.TermBufferLength; + var positionBitsToShift = LogBufferDescriptor.PositionBitsToShift(termBufferLength); + var targetActiveTermId = LogBufferDescriptor.ComputeTermIdFromPosition( + position, positionBitsToShift, initialTermId); + var targetTermOffset = (int)(position & (termBufferLength - 1L)); + controller.SetSetupAtPositionTarget( + TestContexts.StreamId, initialTermId, targetActiveTermId, targetTermOffset); + } + + private sealed class ResumeDescriptorReader : IRecordingDescriptorConsumer + { + public int InitialTermId { get; private set; } + public int TermBufferLength { get; private set; } + + public void OnRecordingDescriptor( + long controlSessionId, long correlationId, long recordingId, long startTimestamp, + long stopTimestamp, long startPosition, long stopPosition, int initialTermId, + int segmentFileLength, int termBufferLength, int mtuLength, int sessionId, + int streamId, string strippedChannel, string originalChannel, string sourceIdentity) + { + InitialTermId = initialTermId; + TermBufferLength = termBufferLength; + } + } + + [Test, Timeout(20_000)] + public void ShouldRejoinLiveEvenIfNoFragmentsHaveBeenConsumedAfterJoiningFromLive() + { + int controlPort; + lock (Random) { controlPort = Random.Next(40_000, 45_000); } + var pubChannel = + $"aeron:udp?term-length=16m|control=localhost:{controlPort}|control-mode=dynamic|fc=min"; + var subChannel = $"aeron:udp?control=localhost:{controlPort}|group=true"; + + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, pubChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + int unconsumedCount; + lock (Random) { unconsumedCount = Random.Next(0, 3); } + var oldMessages = GenerateRandomPayloads(unconsumedCount); + if (oldMessages.Count > 0) + { + persistentPublication.Persist(oldMessages); + } + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(subChannel) + .StartPosition(PersistentSubscription.FROM_LIVE); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(0)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + + lossController.EnableStreamId(PersistentSubscriptionCtx.LiveStreamId()); + + Tests.ExecuteUntil( + () => !persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(1)); + + lossController.DisableStreamId(); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(0)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(2)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(1)); + + var payloads = GenerateRandomPayloads(3); + persistentPublication.Persist(payloads); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(payloads.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + AssertPayloads(fragmentHandler.ReceivedPayloads, payloads); + } + + [Test, Timeout(60_000)] + public void ShouldReceiveAllMessagesWithLossOnLiveChannel() + { + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + var initialMessages = GenerateFixedPayloads(5, OneKbMessageSize); + persistentPublication.Persist(initialMessages); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + // 30% drop on the live stream-id, set up BEFORE PS reaches live so SETUPs go through + // but post-attach DATA frames are sampled for drop. + lossController.EnableStreamIdFrameDataRandom(TestContexts.StreamId, 0.3); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var liveMessages = GenerateFixedPayloads(30, OneKbMessageSize); + persistentPublication.Publish(liveMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(initialMessages.Count + liveMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10), timeoutMs: 50_000); + + AssertPayloads(fragmentHandler.ReceivedPayloads, initialMessages, liveMessages); + lossController.DisableStreamIdFrameData(); + } + + [Test, Timeout(60_000)] + public void ShouldTransitionToLiveThroughLossyReplayWhilePublishing() + { + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + var initialMessages = GenerateFixedPayloads(20, OneKbMessageSize); + persistentPublication.Persist(initialMessages); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + lossController.EnableStreamIdFrameDataRandom( + PersistentSubscriptionCtx.ReplayStreamId(), 0.5); + + // Drain a few before publishing more — PS will still be catching up via lossy replay. + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(initialMessages.Count / 2), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var liveMessages = GenerateFixedPayloads(20, OneKbMessageSize); + persistentPublication.Publish(liveMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(initialMessages.Count + liveMessages.Count) + && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10), timeoutMs: 50_000); + + AssertPayloads(fragmentHandler.ReceivedPayloads, initialMessages, liveMessages); + lossController.DisableStreamIdFrameData(); + } + + [Test, Timeout(60_000)] + public void ShouldReconnectToTheArchiveAfterArchiveRestart() + { + var remoteAeronDir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "aeron-remote-" + Guid.NewGuid().ToString("N")); + var remoteDriver = new EmbeddedMediaDriver(remoteAeronDir); + Closeables.Add(remoteDriver); + var remoteAeron = AeronClient.Connect(new AeronClient.Context().AeronDirectoryName(remoteAeronDir)); + Closeables.Add(remoteAeron); + + int archivePort; + lock (Random) { archivePort = Random.Next(35_000, 40_000); } + var archiveControlChannel = $"aeron:udp?endpoint=localhost:{archivePort}"; + var remoteArchiveDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "aeron-archive-remote-" + Guid.NewGuid().ToString("N")); + + var remoteArchive = new EmbeddedArchive( + remoteAeronDir, + remoteArchiveDir, + deleteArchiveOnStart: false, + controlChannel: archiveControlChannel, + aeronClient: remoteAeron); + Closeables.Add(remoteArchive); + + var remoteAeronArchiveCtx = new AeronArchive.Context() + .ControlRequestChannel(archiveControlChannel) + .ControlResponseChannel(TestContexts.LocalhostControlResponseChannel) + .AeronClient(remoteAeron); + var remoteAeronArchive = AeronArchive.Connect(remoteAeronArchiveCtx.Clone()); + Closeables.Add(remoteAeronArchive); + + using var exclusivePublication = Aeron.AddExclusivePublication( + MdcPublicationChannel, TestContexts.StreamId); + remoteAeronArchive.StartRecording( + MdcSubscriptionChannel, TestContexts.StreamId, Adaptive.Archiver.Codecs.SourceLocation.REMOTE); + Tests.Await(() => exclusivePublication.IsConnected); + + var persistentPublication = PersistentPublication.Create(remoteAeronArchive, exclusivePublication); + Closeables.Add(persistentPublication); + + var firstBatch = GenerateFixedPayloads(1, OneKbMessageSize); + var secondBatch = GenerateFixedPayloads(1, OneKbMessageSize); + var thirdBatch = GenerateFixedPayloads(64, OneKbMessageSize); + + persistentPublication.Persist(firstBatch); + + PersistentSubscriptionCtx + .Aeron(Aeron) + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(persistentPublication.RecordingId) + .AeronArchiveContext( + new AeronArchive.Context() + .ControlRequestChannel(archiveControlChannel) + .ControlResponseChannel(TestContexts.LocalhostControlResponseChannel) + .AeronClient(Aeron)) + .StartPosition(PersistentSubscription.FROM_START); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1), timeoutMs: 30_000); + + Assert.That( + fragmentHandler.ReceivedPayloads.Count, + Is.EqualTo(persistentPublication.PublishedMessageCount)); + + persistentPublication.Persist(secondBatch); + persistentPublication.Persist(thirdBatch); + + DisposeWithTimeout(remoteAeronArchive, 3_000, "remoteAeronArchive"); + DisposeWithTimeout(remoteAeron, 3_000, "remoteAeron"); + remoteArchive.KillProcess(); + remoteDriver.Dispose(); + + // Restart remote driver, aeron, and archive at the same paths. + var remoteDriver2 = new EmbeddedMediaDriver(remoteAeronDir); + Closeables.Add(remoteDriver2); + var remoteAeron2 = AeronClient.Connect(new AeronClient.Context().AeronDirectoryName(remoteAeronDir)); + Closeables.Add(remoteAeron2); + var remoteArchive2 = new EmbeddedArchive( + remoteAeronDir, + remoteArchiveDir, + deleteArchiveOnStart: false, + controlChannel: archiveControlChannel, + aeronClient: remoteAeron2); + Closeables.Add(remoteArchive2); + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 10), timeoutMs: 30_000); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10), timeoutMs: 30_000); + + AssertPayloads(fragmentHandler.ReceivedPayloads, firstBatch, secondBatch, thirdBatch); + } + + [Test, Timeout(60_000)] + public void ShouldRecoverFromArchiveRestartDuringReplay() + { + var remoteAeronDir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "aeron-remote-" + Guid.NewGuid().ToString("N")); + var remoteDriver = new EmbeddedMediaDriver(remoteAeronDir); + Closeables.Add(remoteDriver); + var remoteAeron = AeronClient.Connect(new AeronClient.Context().AeronDirectoryName(remoteAeronDir)); + Closeables.Add(remoteAeron); + + int archivePort; + lock (Random) + { + archivePort = Random.Next(35_000, 40_000); + } + + var archiveControlChannel = $"aeron:udp?endpoint=localhost:{archivePort}"; + var remoteArchiveDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "aeron-archive-remote-" + Guid.NewGuid().ToString("N")); + + var remoteArchive = new EmbeddedArchive( + remoteAeronDir, + remoteArchiveDir, + deleteArchiveOnStart: false, + controlChannel: archiveControlChannel, aeronClient: remoteAeron); + Closeables.Add(remoteArchive); + + var remoteAeronArchiveCtx = new AeronArchive.Context() + .ControlRequestChannel(archiveControlChannel) + .ControlResponseChannel(TestContexts.LocalhostControlResponseChannel) + .AeronClient(remoteAeron); + var remoteAeronArchive = AeronArchive.Connect(remoteAeronArchiveCtx.Clone()); + Closeables.Add(remoteAeronArchive); + + using var exclusivePublication = Aeron.AddExclusivePublication( + MdcPublicationChannel, TestContexts.StreamId); + remoteAeronArchive.StartRecording( + MdcSubscriptionChannel, TestContexts.StreamId, Adaptive.Archiver.Codecs.SourceLocation.REMOTE); + Tests.Await(() => exclusivePublication.IsConnected); + + var persistentPublication = PersistentPublication.Create(remoteAeronArchive, exclusivePublication); + Closeables.Add(persistentPublication); + + // Publish enough so PS will still be replaying when we kill the archive. + var messages = GenerateFixedPayloads(80, OneKbMessageSize); + persistentPublication.Persist(messages); + + PersistentSubscriptionCtx + .Aeron(Aeron) + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(persistentPublication.RecordingId) + .AeronArchiveContext( + new AeronArchive.Context() + .ControlRequestChannel(archiveControlChannel) + .ControlResponseChannel(TestContexts.LocalhostControlResponseChannel) + .AeronClient(Aeron)) + .StartPosition(PersistentSubscription.FROM_START); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(5), + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(persistentSubscription.IsReplaying, Is.True); + + // Kill archive while PS is still replaying. + DisposeWithTimeout(remoteAeronArchive, 3_000, "remoteAeronArchive"); + DisposeWithTimeout(remoteAeron, 3_000, "remoteAeron"); + remoteArchive.KillProcess(); + remoteDriver.Dispose(); + + Tests.ExecuteUntil( + () => !persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 1), timeoutMs: 30_000); + Assert.That(persistentSubscription.HasFailed, Is.False); + + // Restart remote driver, aeron, archive. + var remoteDriver2 = new EmbeddedMediaDriver(remoteAeronDir); + Closeables.Add(remoteDriver2); + var remoteAeron2 = AeronClient.Connect(new AeronClient.Context().AeronDirectoryName(remoteAeronDir)); + Closeables.Add(remoteAeron2); + var remoteArchive2 = new EmbeddedArchive( + remoteAeronDir, + remoteArchiveDir, + deleteArchiveOnStart: false, + controlChannel: archiveControlChannel, + aeronClient: remoteAeron2); + Closeables.Add(remoteArchive2); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10), timeoutMs: 30_000); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + } + + [TestCase(PersistentSubscription.FROM_START), TestCase(PersistentSubscription.FROM_LIVE)] + [Timeout(20_000)] + public void ShouldRetryAndRecoverWhenArchiveIsNotAvailableDuringStartUp(long startPosition) + { + // Replace the setUp archive with a local archive whose dir we can preserve across a + // SIGKILL+restart cycle (deleteArchiveOnStart=false, KillProcess instead of Dispose). + DisposeWithTimeout(AeronArchive, 3_000, "AeronArchive (setUp)"); + AeronArchive = null; + Archive.Dispose(); + + var localArchiveDir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "aeron-archive-restart-" + Guid.NewGuid().ToString("N")); + Archive = new EmbeddedArchive( + Driver.AeronDirectoryName, localArchiveDir, deleteArchiveOnStart: false, aeronClient: Aeron); + AeronArchive = AeronArchive.Connect(CloneArchiveCtx()); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + persistentPublication.Persist(GenerateRandomPayloads(1)); + + var archiveControlChannel = Archive.ControlRequestChannel; + var localArchive = Archive; + Closeables.Add(localArchive); + + DisposeWithTimeout(AeronArchive, 3_000, "AeronArchive (pre-restart)"); + AeronArchive = null; + localArchive.KillProcess(); + Archive = null; + + // PersistentSubscriptionCtx.AeronArchiveContext was set up in SetUp pointing at the + // (now-disposed) setUp archive's channels. Re-bind to the local archive's channels — + // otherwise PS would try to connect on a dead port and retry forever. + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(startPosition) + .LiveChannel(MdcSubscriptionChannel) + .AeronArchiveContext( + new AeronArchive.Context() + .ControlRequestChannel(archiveControlChannel) + .ControlResponseChannel(TestContexts.LocalhostControlResponseChannel) + .AeronClient(Aeron) + .MessageTimeoutNs(500_000_000L)); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil(() => Listener.ErrorCount > 1, + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(Listener.LastException, Is.InstanceOf()); + Assert.That(persistentSubscription.HasFailed, Is.False); + Assert.That(persistentSubscription.FailureReason, Is.Null); + + Archive = new EmbeddedArchive( + Driver.AeronDirectoryName, localArchiveDir, deleteArchiveOnStart: false, + controlChannel: archiveControlChannel, aeronClient: Aeron); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + } + + [Test, Timeout(15_000)] + public void ShouldCloseArchiveConnectionOnFailureInCaseApplicationKeepsPolling() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + // Misaligned start position to force PS into FAILED state. + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(8192); + + var fragmentHandler = new BufferingFragmentHandler(); + + // Capture baseline before PS opens its own archive session (baseline includes the + // test's AeronArchive session and any infrastructure sessions such as the probe). + var baselineSessionCount = ReadArchiveControlSessionsCount(); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.HasFailed, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + // After FAILED, PS's SetState(FAILED) disposes its _asyncAeronArchive, which sends + // a close request to the archive. Session count must return to baseline. + Tests.ExecuteUntil( + () => ReadArchiveControlSessionsCount() == baselineSessionCount, + () => Poll(persistentSubscription, fragmentHandler, 1)); + } + + private long ReadArchiveControlSessionsCount() + { + var counters = Aeron.CountersReader; + long value = -1; + counters.ForEach((counterId, typeId, keyBuffer, label) => + { + if (typeId == Adaptive.Aeron.AeronCounters.ARCHIVE_CONTROL_SESSIONS_TYPE_ID) + { + value = counters.GetCounterValue((int)counterId); + } + }); + return value; + } + + [Test, Timeout(15_000)] + public void ShouldContinueConsumingFromLiveWhileArchiveIsUnavailable() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var firstBatch = GenerateRandomPayloads(5); + var secondBatch = GenerateRandomPayloads(5); + persistentPublication.Persist(firstBatch); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount) + && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + // Kill the archive JVM. PS is already in LIVE so its archive control session is idle; + // it should keep consuming the live channel without noticing. + DisposeWithTimeout(AeronArchive, 3_000, "AeronArchive (mid-test)"); + AeronArchive = null; + Archive.Dispose(); + Archive = null; + + // publish() goes via the local ExclusivePublication on the driver — the archive + // process is not in the path, so this still works. + persistentPublication.Publish(secondBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, firstBatch, secondBatch); + } + + [Test, Timeout(20_000)] + public void ShouldRecoverWhenThePersistentPublicationIsRestartedDuringReplay() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + var recordedBatch = GenerateRandomPayloads(1); + persistentPublication.Persist(recordedBatch); + var recordingId = persistentPublication.RecordingId; + + // Pre-create the state counter so the test can poll it directly. Java accesses + // the auto-created one via persistentSubscription.context().stateCounter() which + // .NET doesn't expose; supplying our own is equivalent. + using var stateCounter = Aeron.AddCounter( + Adaptive.Aeron.AeronCounters.PERSISTENT_SUBSCRIPTION_STATE_TYPE_ID, + "test PS state"); + + PersistentSubscriptionCtx + .StartPosition(PersistentSubscription.FROM_START) + .RecordingId(recordingId) + .StateCounter(stateCounter); + PersistentSubscriptionCtx + .AeronArchiveContext().MessageTimeoutNs(500_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(persistentPublication.PublishedMessageCount), + () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(persistentSubscription.IsReplaying, Is.True); + + persistentPublication.Dispose(); + + // Wait for PS to reach end of recording and transition to AWAIT_LIVE (state 15). + Tests.ExecuteUntil(() => stateCounter.Get() == 15, () => Poll(persistentSubscription, fragmentHandler, 10)); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var batchAfterResuming = GenerateRandomPayloads(5); + resumedPublication.Persist(batchAfterResuming); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(batchAfterResuming.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, recordedBatch, batchAfterResuming); + } + + [TestCase("aeron:udp?endpoint=localhost:0", -10)] + [TestCase("aeron:ipc", -12)] + [Timeout(15_000)] + public void ShouldReplayOverConfiguredChannel(string replayChannel, int replayStreamId) + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var payloads = GenerateRandomPayloads(5); + persistentPublication.Persist(payloads); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .ReplayChannel(replayChannel) + .ReplayStreamId(replayStreamId); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(1), + () => Poll(persistentSubscription, fragmentHandler, 1)); + + Assert.That(persistentSubscription.IsReplaying, Is.True); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, payloads); + } + + [TestCase(1, "ipc"), TestCase(10, "ipc"), TestCase(int.MaxValue, "ipc")] + [TestCase(1, "multicast"), TestCase(10, "multicast"), TestCase(int.MaxValue, "multicast")] + [TestCase(1, "spy"), TestCase(10, "spy"), TestCase(int.MaxValue, "spy")] + [Timeout(15_000)] + public void ShouldConsumeLiveOverConfiguredChannel(int fragmentLimit, string channelKind) + { + string subChannel, pubChannel; + switch (channelKind) + { + case "ipc": + subChannel = pubChannel = TestContexts.IpcChannel; + break; + case "multicast": + subChannel = pubChannel = TestContexts.MulticastChannel; + break; + case "spy": + subChannel = AeronClient.Context.SPY_PREFIX + MdcPublicationChannel; + pubChannel = MdcPublicationChannel; + break; + default: + throw new System.ArgumentException(channelKind); + } + + var persistentPublication = PersistentPublication.Create( + AeronArchive, pubChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(subChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, fragmentLimit)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + + var payloads = GenerateRandomPayloads(5); + persistentPublication.Persist(payloads); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(1), + () => Poll(persistentSubscription, fragmentHandler, 1)); + + Assert.That(persistentSubscription.IsLive, Is.True); + Assert.That(persistentSubscription.IsReplaying, Is.False); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(payloads.Count), + () => Poll(persistentSubscription, fragmentHandler, fragmentLimit)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, payloads); + } + + [TestCase("unicast"), TestCase("ipc"), Timeout(30_000)] + public void AnUntetheredPersistentSubscriptionCanFallBehindATetheredSubscription(string channelKind) + { + string channel; + if (channelKind == "unicast") + { + int port; + lock (Random) + { + port = Random.Next(30_000, 35_000); + } + channel = $"aeron:udp?endpoint=localhost:{port}"; + } + else + { + channel = TestContexts.IpcChannel; + } + var channelUriStringBuilder = new Adaptive.Aeron.ChannelUriStringBuilder(channel); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, channel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(channelUriStringBuilder.Tether(false).Build()); + + var fragmentHandler = new BufferingFragmentHandler(); + var fastHandler = new CountingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + using var fastSubscription = Aeron.AddSubscription( + new Adaptive.Aeron.ChannelUriStringBuilder(channel).Tether(true).Build(), + TestContexts.StreamId); + Tests.AwaitConnected(fastSubscription); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + persistentPublication.Persist(GenerateFixedPayloads(32, OneKbMessageSize)); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(32), + () => fastSubscription.Poll(fastHandler, 10)); + + persistentPublication.Persist(GenerateFixedPayloads(32, OneKbMessageSize)); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(64), + () => fastSubscription.Poll(fastHandler, 10)); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(64), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsReplaying, Is.True); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + } + + [Test, Timeout(30_000)] + public void UntetheredSpyCanFallbackToReplay() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_LIVE) + .LiveChannel(AeronClient.Context.SPY_PREFIX + MdcPublicationChannel + "|tether=false"); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + Assert.That(fragmentHandler.ReceivedPayloads.Count, Is.EqualTo(0)); + + using var fastDriver = new EmbeddedMediaDriver(); + using var fastAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(fastDriver.AeronDirectoryName)); + using var fastConsumer = fastAeron.AddSubscription(MdcSubscriptionChannel, TestContexts.StreamId); + var fastHandler = new CountingFragmentHandler(); + Tests.AwaitConnected(fastConsumer); + + var firstBatch = new List(); + for (var i = 0; i < 3; i++) + { + var batch = GenerateFixedPayloads(32, OneKbMessageSize); + persistentPublication.Publish(batch); + firstBatch.AddRange(batch); + Tests.ExecuteUntil( + () => fastHandler.HasReceivedPayloads(firstBatch.Count), + () => fastConsumer.Poll(fastHandler, 10)); + } + + Tests.ExecuteUntil( + () => persistentSubscription.IsReplaying, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(1)); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(firstBatch.Count) && persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, firstBatch); + } + + [Test, Timeout(30_000)] + public void ShouldCatchUpWhenStartingAtStopPositionAndRecordingResumes() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + + persistentPublication.Persist(GenerateRandomPayloads(1)); + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + var recordingId = persistentPublication.RecordingId; + persistentPublication.ClosePublicationOnly(); + + PersistentSubscriptionCtx + .RecordingId(recordingId) + .StartPosition(stopPosition) + .AeronArchiveContext().MessageTimeoutNs(5_000_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(Listener.LastException.Message, + Does.Contain("No image became available on the live subscription")); + Assert.That(persistentSubscription.IsLive, Is.False); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + var messages = GenerateRandomPayloads(3); + resumedPublication.Persist(messages); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + } + + [Test, Timeout(15_000)] + public void ShouldCatchUpWhenStartingAtStopPositionOfExtendedRecording() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + + persistentPublication.Persist(GenerateRandomPayloads(1)); + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + var recordingId = persistentPublication.RecordingId; + persistentPublication.ClosePublicationOnly(); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + var catchupMessages = GenerateRandomPayloads(3); + resumedPublication.Persist(catchupMessages); + + PersistentSubscriptionCtx + .RecordingId(recordingId) + .StartPosition(stopPosition) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(catchupMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var liveMessages = GenerateRandomPayloads(2); + resumedPublication.Persist(liveMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(catchupMessages.Count + liveMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, catchupMessages, liveMessages); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + } + + [Test, Timeout(30_000)] + public void ShouldJoinLiveUponReachingEndOfRecordingWhenLiveBecomesAvailable() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + + var oldMessages = GenerateFixedPayloads(8, OneKbMessageSize); + persistentPublication.Persist(oldMessages); + + persistentPublication.Dispose(); + Tests.Await(() => !persistentPublication.PublicationCountersExist()); + + var recordingId = persistentPublication.RecordingId; + PersistentSubscriptionCtx + .LiveChannel(MdcSubscriptionChannel) + .RecordingId(recordingId) + .AeronArchiveContext().MessageTimeoutNs(5_000_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(oldMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, oldMessages); + + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LastException.Message, + Does.Contain("No image became available on the live subscription")); + Assert.That(persistentSubscription.HasFailed, Is.False); + Assert.That(persistentSubscription.FailureReason, Is.Null); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var newMessages = GenerateFixedPayloads(16, OneKbMessageSize); + resumedPublication.Persist(newMessages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(oldMessages.Count + newMessages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Assert.That(persistentSubscription.IsLive, Is.True); + Assert.That(persistentSubscription.IsReplaying, Is.False); + AssertPayloads(fragmentHandler.ReceivedPayloads, oldMessages, newMessages); + } + + [Test, Timeout(20_000)] + public void ShouldHandOffToLiveWhenReplayCatchesUpAtPublisherJoinPosition() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var recordedBatch = GenerateFixedPayloads(3, OneKbMessageSize); + persistentPublication.Persist(recordedBatch); + var stopPosition = persistentPublication.Stop(); + Assert.That(stopPosition, Is.GreaterThan(0)); + var recordingId = persistentPublication.RecordingId; + persistentPublication.ClosePublicationOnly(); + + // Wait for residual state on publisher A to drain before bringing B up at the same position. + Tests.Await(() => !persistentPublication.PublicationCountersExist()); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + + PersistentSubscriptionCtx + .RecordingId(recordingId) + .StartPosition(PersistentSubscription.FROM_START) + .LiveChannel(MdcSubscriptionChannel); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(recordedBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + + var liveBatch = GenerateFixedPayloads(3, OneKbMessageSize); + resumedPublication.Persist(liveBatch); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(recordedBatch.Count + liveBatch.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, recordedBatch, liveBatch); + } + + [Test, Timeout(30_000)] + public void ShouldRecoverWhenThePersistentPublicationIsRestartedWhileOnLive() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + persistentPublication.Persist(GenerateRandomPayloads(1)); + var recordingId = persistentPublication.RecordingId; + + PersistentSubscriptionCtx + .StartPosition(PersistentSubscription.FROM_LIVE) + .RecordingId(recordingId) + .AeronArchiveContext().MessageTimeoutNs(5_000_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + persistentPublication.Dispose(); + + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(Listener.LastException.Message, + Does.Contain("No image became available on the live subscription")); + Assert.That(persistentSubscription.IsLive, Is.False); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var messages = GenerateRandomPayloads(5); + resumedPublication.Persist(messages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + } + + [Test, Timeout(10_000)] + public void ShouldAssembleMessages() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + var sizeRequiringFragmentation = persistentPublication.MaxPayloadLength + 1; + var payload0 = GenerateFixedPayloads(1, sizeRequiringFragmentation); + var payload1 = GenerateFixedPayloads(1, sizeRequiringFragmentation); + + persistentPublication.Persist(payload0); + + PersistentSubscriptionCtx.RecordingId(persistentPublication.RecordingId); + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 1)); + + persistentPublication.Persist(payload1); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(2), + () => Poll(persistentSubscription, fragmentHandler, 1)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, payload0, payload1); + } + + [Test, Timeout(30_000)] + public void ShouldRetryAndRecoverWhenLiveIsNotAvailableDuringStartUp() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId); + persistentPublication.Persist(GenerateRandomPayloads(1)); + var recordingId = persistentPublication.RecordingId; + persistentPublication.Dispose(); + + PersistentSubscriptionCtx + .StartPosition(PersistentSubscription.FROM_LIVE) + .RecordingId(recordingId) + .AeronArchiveContext().MessageTimeoutNs(5_000_000_000L); + + var fragmentHandler = new BufferingFragmentHandler(); + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + + Tests.ExecuteUntil(() => Listener.ErrorCount > 0, () => Poll(persistentSubscription, fragmentHandler, 1)); + Assert.That(Listener.LastException.Message, + Does.Contain("No image became available on the live subscription")); + Assert.That(persistentSubscription.HasFailed, Is.False); + Assert.That(persistentSubscription.FailureReason, Is.Null); + Assert.That(persistentSubscription.IsLive, Is.False); + + var resumedPublication = PersistentPublication.Resume( + AeronArchive, TestContexts.IpcChannel, TestContexts.StreamId, recordingId); + Closeables.Add(resumedPublication); + + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var messages = GenerateRandomPayloads(5); + resumedPublication.Persist(messages); + + Tests.ExecuteUntil( + () => fragmentHandler.HasReceivedPayloads(messages.Count), + () => Poll(persistentSubscription, fragmentHandler, 10)); + + AssertPayloads(fragmentHandler.ReceivedPayloads, messages); + } + + [Test, Timeout(10_000)] + public void ShouldCreateOwnAeronInstanceWhenNotSupplied() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + persistentPublication.Persist(GenerateRandomPayloads(2)); + + var ctx = new PersistentSubscription.Context() + .AeronDirectoryName(Driver.AeronDirectoryName) + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_START) + .LiveChannel(MdcSubscriptionChannel) + .LiveStreamId(TestContexts.StreamId) + .ReplayChannel(TestContexts.EphemeralReplayChannel) + .ReplayStreamId(-5) + .Listener(Listener) + .AeronArchiveContext(CloneArchiveCtx()); + + var fragmentHandler = new BufferingFragmentHandler(); + var persistentSubscription = PersistentSubscription.Create(ctx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + var ownAeron = ctx.Aeron(); + Assert.That(ownAeron, Is.Not.Null); + persistentSubscription.Dispose(); + Assert.That(ownAeron.IsClosed, Is.True); + } + + [Test, Timeout(10_000)] + public void ShouldNotCloseSuppliedAeronInstance() + { + var persistentPublication = PersistentPublication.Create( + AeronArchive, MdcPublicationChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + persistentPublication.Persist(GenerateRandomPayloads(2)); + + var ctx = PersistentSubscriptionCtx.Clone() + .Aeron(Aeron) + .RecordingId(persistentPublication.RecordingId) + .StartPosition(PersistentSubscription.FROM_START) + .LiveChannel(MdcSubscriptionChannel) + .AeronArchiveContext(CloneArchiveCtx().AeronClient(Aeron)); + + var fragmentHandler = new BufferingFragmentHandler(); + var persistentSubscription = PersistentSubscription.Create(ctx); + Tests.ExecuteUntil( + () => persistentSubscription.IsLive, + () => Poll(persistentSubscription, fragmentHandler, 10)); + + persistentSubscription.Dispose(); + Assert.That(Aeron.IsClosed, Is.False); + } + + [Test, Timeout(40_000)] + public void ShouldRecoverFromReplayChannelNetworkProblems() + { + ShouldRecoverFromNetworkProblems(NetworkFlow.Replay); + } + + [Test, Timeout(40_000)] + public void ShouldRecoverFromLiveChannelNetworkProblems() + { + ShouldRecoverFromNetworkProblems(NetworkFlow.Live); + } + + private enum NetworkFlow { Replay, Live } + + private void ShouldRecoverFromNetworkProblems(NetworkFlow victimFlow) + { + int controlPort; + lock (Random) { controlPort = Random.Next(40_000, 45_000); } + var pubChannel = + $"aeron:udp?term-length=16m|control=localhost:{controlPort}|control-mode=dynamic|fc=min"; + var subChannel = $"aeron:udp?control=localhost:{controlPort}|group=true"; + + using var lossDriver = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-loss-" + Guid.NewGuid().ToString("N")), + withLossGenerators: true); + using var lossAeron = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(lossDriver.AeronDirectoryName)); + using var lossController = new LossGenController(lossAeron); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, pubChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + PersistentSubscriptionCtx + .Aeron(lossAeron) + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(subChannel); + + const int ratePerSecond = 10_000; + + using var publisher = new BackgroundPublisher(persistentPublication, ratePerSecond); + + System.Threading.Thread.Sleep(1_000); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + var handler = new MessageVerifier(); + + // matches EmbeddedMediaDriver -Daeron.image.liveness.timeout=2s + const int imageLivenessTimeoutMs = 2_000; + var lossState = LossState.NotStarted; + long deadlineTicks = 0; + + while (!persistentSubscription.IsLive) + { + if (Poll(persistentSubscription, handler, 10) == 0) + { + Tests.YieldingIdle("failed to transition to live"); + } + + if (victimFlow == NetworkFlow.Replay) + { + if (lossState == LossState.NotStarted && persistentSubscription.IsReplaying) + { + lossState = LossState.WaitingToStart; + deadlineTicks = System.Diagnostics.Stopwatch.GetTimestamp() + + TicksFromMillis(500); + } + + if (lossState == LossState.WaitingToStart + && System.Diagnostics.Stopwatch.GetTimestamp() - deadlineTicks >= 0) + { + lossState = LossState.InProgress; + deadlineTicks = System.Diagnostics.Stopwatch.GetTimestamp() + + TicksFromMillis(imageLivenessTimeoutMs + 200); + lossController.EnableStreamId(PersistentSubscriptionCtx.ReplayStreamId()); + } + + if (lossState == LossState.InProgress + && System.Diagnostics.Stopwatch.GetTimestamp() - deadlineTicks >= 0) + { + lossState = LossState.Finished; + lossController.DisableStreamId(); + } + } + } + + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(1)); + Assert.That(Listener.LiveLeftCount, Is.EqualTo(0)); + + if (victimFlow == NetworkFlow.Live) + { + lossState = LossState.WaitingToStart; + deadlineTicks = System.Diagnostics.Stopwatch.GetTimestamp() + TicksFromMillis(500); + + while (true) + { + if (Poll(persistentSubscription, handler, 10) == 0) + { + Tests.YieldingIdle("interrupted while simulating live channel network problems"); + } + + if (lossState == LossState.WaitingToStart + && System.Diagnostics.Stopwatch.GetTimestamp() - deadlineTicks >= 0) + { + lossState = LossState.InProgress; + lossController.EnableStreamId(PersistentSubscriptionCtx.LiveStreamId()); + } + + if (lossState == LossState.InProgress && !persistentSubscription.IsLive) + { + Assert.That(Listener.LiveLeftCount, Is.EqualTo(1)); + lossState = LossState.Finished; + lossController.DisableStreamId(); + } + + if (lossState == LossState.Finished && persistentSubscription.IsLive) + { + Assert.That(Listener.LiveJoinedCount, Is.EqualTo(2)); + break; + } + } + } + + publisher.Dispose(); + var lastPosition = persistentPublication.Position; + // Drain the stream so the verifier sees every fragment up to the last published position. + Tests.ExecuteUntil( + () => handler.Position >= lastPosition, + () => Poll(persistentSubscription, handler, 10), 20_000); + } + + private enum LossState { NotStarted, WaitingToStart, InProgress, Finished } + + private static long TicksFromMillis(long millis) + { + return millis * System.Diagnostics.Stopwatch.Frequency / 1_000L; + } + + [Test, Timeout(90_000)] + public void CanJoinLiveWhenLiveAndReplayAreAdvancing() + { + int controlPort; + lock (Random) { controlPort = Random.Next(40_000, 45_000); } + var pubChannel = + $"aeron:udp?term-length=16m|control=localhost:{controlPort}|control-mode=dynamic|fc=min"; + var subChannel = $"aeron:udp?control=localhost:{controlPort}|group=true"; + + using var driver2 = new EmbeddedMediaDriver( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "aeron-d2-" + Guid.NewGuid().ToString("N"))); + using var aeron2 = AeronClient.Connect( + new AeronClient.Context().AeronDirectoryName(driver2.AeronDirectoryName)); + + var persistentPublication = PersistentPublication.Create( + AeronArchive, pubChannel, TestContexts.StreamId); + Closeables.Add(persistentPublication); + + using var controlSubscription = Aeron.AddSubscription(subChannel, TestContexts.StreamId); + Tests.AwaitConnected(controlSubscription); + + PersistentSubscriptionCtx + .Aeron(aeron2) + .RecordingId(persistentPublication.RecordingId) + .LiveChannel(subChannel) + .Listener(null); + + const int ratePerSecond = 500; // matches Java upstream + + // Background control consumer: simulates a separate live-only subscriber driving + // ImageAvailable/Unavailable signals, exactly as the Java test does. + using var controlConsumerCts = new System.Threading.CancellationTokenSource(); + var controlTask = System.Threading.Tasks.Task.Run(() => + { + IFragmentHandler controlHandler = new NoopFragmentHandler(); + while (!controlConsumerCts.IsCancellationRequested) + { + controlSubscription.Poll(controlHandler, 10); + } + }); + + using var publisher = new BackgroundPublisher(persistentPublication, ratePerSecond); + + System.Threading.Thread.Sleep(1_000); + + using var persistentSubscription = PersistentSubscription.Create(PersistentSubscriptionCtx); + var handler = new MessageVerifier(); + + // Tight poll loop matching the Java upstream. + var deadline = DateTime.UtcNow.AddSeconds(70); + while (!persistentSubscription.IsLive) + { + Poll(persistentSubscription, handler, 10); + if (DateTime.UtcNow > deadline) + { + Assert.Fail("PS failed to transition to live within 70s"); + } + } + + publisher.Dispose(); + var lastPosition = persistentPublication.Position; + + var drainDeadline = DateTime.UtcNow.AddSeconds(20); + while (handler.Position < lastPosition) + { + Poll(persistentSubscription, handler, 10); + if (DateTime.UtcNow > drainDeadline) + { + Assert.Fail($"failed to drain stream: handler={handler.Position} last={lastPosition}"); + } + } + + controlConsumerCts.Cancel(); + try { controlTask.Wait(TimeSpan.FromSeconds(5)); } catch { } + } + + private sealed class NoopFragmentHandler : IFragmentHandler + { + public void OnFragment(Agrona.IDirectBuffer buffer, int offset, int length, Header header) + { + } + } + } +} diff --git a/src/Adaptive.Archiver.IntegrationTests/UncontrolledPollingPersistentSubscriptionTest.cs b/src/Adaptive.Archiver.IntegrationTests/UncontrolledPollingPersistentSubscriptionTest.cs new file mode 100644 index 00000000..aa4f59ba --- /dev/null +++ b/src/Adaptive.Archiver.IntegrationTests/UncontrolledPollingPersistentSubscriptionTest.cs @@ -0,0 +1,28 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Adaptive.Aeron.LogBuffer; +using NUnit.Framework; + +namespace Adaptive.Archiver.IntegrationTests +{ + [TestFixture] + internal class UncontrolledPollingPersistentSubscriptionTest : PersistentSubscriptionTest + { + protected override int Poll(PersistentSubscription subscription, IFragmentHandler handler, int fragmentLimit) => + subscription.Poll(handler, fragmentLimit); + } +} diff --git a/src/Adaptive.Archiver.Tests/AeronArchiveTest.cs b/src/Adaptive.Archiver.Tests/AeronArchiveTest.cs index a7b85eea..30959fe8 100644 --- a/src/Adaptive.Archiver.Tests/AeronArchiveTest.cs +++ b/src/Adaptive.Archiver.Tests/AeronArchiveTest.cs @@ -377,7 +377,7 @@ public void ShouldRejectInvalidRetryAttempts(int retryAttempts) var exception = Assert.Throws(() => context.Conclude()); Assert.AreEqual( - "AeronArchive.Context.messageRetryAttempts must be > 0, got: " + retryAttempts, + "ERROR - AeronArchive.Context.messageRetryAttempts must be > 0, got: " + retryAttempts, exception.Message ); } diff --git a/src/Adaptive.Archiver.Tests/PersistentSubscriptionContextTest.cs b/src/Adaptive.Archiver.Tests/PersistentSubscriptionContextTest.cs new file mode 100644 index 00000000..ecf787fd --- /dev/null +++ b/src/Adaptive.Archiver.Tests/PersistentSubscriptionContextTest.cs @@ -0,0 +1,307 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using Adaptive.Aeron; +using Adaptive.Aeron.Exceptions; +using Adaptive.Agrona.Concurrent; +using Adaptive.Agrona.Concurrent.Status; +using FakeItEasy; +using NUnit.Framework; +using AeronType = Adaptive.Aeron.Aeron; + +namespace Adaptive.Archiver.Tests +{ + public class PersistentSubscriptionContextTest + { + private const string IpcChannel = "aeron:ipc"; + + private PersistentSubscription.Context _context; + private CountersManager _countersManager; + + [SetUp] + public void SetUp() + { + AeronType aeron = A.Fake(); + UnsafeBuffer metaDataBuffer = new UnsafeBuffer(new byte[128 * 1024]); + UnsafeBuffer valuesBuffer = new UnsafeBuffer(new byte[64 * 1024]); + _countersManager = new CountersManager(metaDataBuffer, valuesBuffer); + + A.CallTo(() => aeron.AddCounter(A._, A._)) + .ReturnsLazily((int typeId, string label) => + { + int counterId = _countersManager.Allocate(label, typeId); + return new Counter(_countersManager, counterId); + }); + + _context = new PersistentSubscription.Context() + .RecordingId(1) + .LiveChannel(IpcChannel) + .LiveStreamId(1) + .ReplayChannel(IpcChannel) + .ReplayStreamId(2) + .Aeron(aeron) + .AeronArchiveContext(new AeronArchive.Context()); + } + + [Test] + public void CanOnlyConcludeOnce() + { + _context.Conclude(); + + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void ContextMustHaveAnArchiveContext() + { + _context.AeronArchiveContext(null); + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void ContextMustHaveRecordingId() + { + _context.RecordingId(AeronType.NULL_VALUE); + Assert.Throws(() => _context.Conclude()); + } + + [TestCase(null)] + [TestCase("")] + public void ContextMustHaveLiveChannel(string channel) + { + _context.LiveChannel(channel); + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void ContextMustHaveLiveStreamId() + { + _context.LiveStreamId(AeronType.NULL_VALUE); + Assert.Throws(() => _context.Conclude()); + } + + [TestCase(null)] + [TestCase("")] + public void ContextMustHaveReplayChannel(string channel) + { + _context.ReplayChannel(channel); + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void MustNotRejoinOnReplayChannels() + { + ChannelUriStringBuilder builder = new ChannelUriStringBuilder("aeron:udp?endpoint=localhost:0"); + string configuredReplayChannel = builder.Rejoin(true).Build(); + + _context.ReplayChannel(configuredReplayChannel); + _context.Conclude(); + string actualReplayChannel = _context.ReplayChannel(); + ChannelUriStringBuilder channelUri = new ChannelUriStringBuilder(actualReplayChannel); + Assert.IsFalse(channelUri.Rejoin().GetValueOrDefault(false)); + } + + [Test] + public void ContextMustHaveReplayStreamId() + { + _context.ReplayStreamId(AeronType.NULL_VALUE); + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void ContextThrowsIfStartPositionIsInvalid() + { + _context.StartPosition(-3); + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void ContextThrowsIfRecordingIdIsInvalid() + { + _context.RecordingId(-2); + Assert.Throws(() => _context.Conclude()); + } + + [Test] + public void ContextCanBeCloned() + { + PersistentSubscription.Context clonedCtx = _context.Clone(); + + Assert.AreNotSame(_context, clonedCtx); + + Assert.AreEqual(_context.StartPosition(), clonedCtx.StartPosition()); + Assert.AreEqual(_context.RecordingId(), clonedCtx.RecordingId()); + Assert.AreEqual(_context.LiveChannel(), clonedCtx.LiveChannel()); + Assert.AreEqual(_context.LiveStreamId(), clonedCtx.LiveStreamId()); + + Assert.AreSame(_context.Listener(), clonedCtx.Listener()); + Assert.AreSame(_context.AeronArchiveContext(), clonedCtx.AeronArchiveContext()); + } + + [Test] + public void ContextShouldCreateListenerIfNoneProvided() + { + _context.Listener(null); + _context.Conclude(); + + Assert.NotNull(_context.Listener()); + } + + [Test] + public void ContextShouldCreateStateCounterIfNoneProvided() + { + _context.StateCounter(null); + _context.Conclude(); + + Assert.NotNull(_context.StateCounter()); + } + + [Test] + public void ContextShouldCreateJoinDifferenceCounterIfNoneProvided() + { + _context.JoinDifferenceCounter(null); + _context.Conclude(); + + Assert.NotNull(_context.JoinDifferenceCounter()); + } + + [Test] + public void ContextShouldCreateLiveLeftCounterIfNoneProvided() + { + _context.LiveLeftCounter(null); + _context.Conclude(); + + Assert.NotNull(_context.LiveLeftCounter()); + } + + [Test] + public void ContextShouldCreateLiveJoinedCounterIfNoneProvided() + { + _context.LiveJoinedCounter(null); + _context.Conclude(); + + Assert.NotNull(_context.LiveJoinedCounter()); + } + + [TestCaseSource(nameof(ReplayAndControlChannels))] + public void ReplayAndControlChannelMediaTypesMustMatchWhenUsingResponseChannels( + bool expectSuccess, + string replayChannel, + string archiveControlRequestChannel, + string archiveControlResponseChannel) + { + _context.ReplayChannel(replayChannel).AeronArchiveContext() + .ControlRequestChannel(archiveControlRequestChannel) + .ControlResponseChannel(archiveControlResponseChannel); + if (expectSuccess) + { + _context.Conclude(); + } + else + { + Assert.Throws(() => _context.Conclude()); + } + } + + private static IEnumerable ReplayAndControlChannels() + { + yield return new object[] { true, "aeron:udp?endpoint=localhost:0", null, null }; + yield return new object[] + { + true, + "aeron:udp?endpoint=localhost:0", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?endpoint=localhost:0" + }; + yield return new object[] + { + true, + "aeron:udp?endpoint=localhost:0", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?control-mode=response|control=localhost:10002" + }; + yield return new object[] { true, "aeron:udp?endpoint=localhost:0", "aeron:ipc", "aeron:ipc" }; + yield return new object[] + { + true, "aeron:udp?endpoint=localhost:0", "aeron:ipc", "aeron:ipc?control-mode=response" + }; + yield return new object[] + { + true, "aeron:udp?control=localhost:10001|control-mode=response", null, null + }; + yield return new object[] + { + true, + "aeron:udp?control=localhost:10001|control-mode=response", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?endpoint=localhost:0" + }; + yield return new object[] + { + true, + "aeron:udp?control=localhost:10001|control-mode=response", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?control-mode=response|control=localhost:10002" + }; + yield return new object[] + { + false, "aeron:udp?control=localhost:10001|control-mode=response", "aeron:ipc", "aeron:ipc" + }; + yield return new object[] + { + false, + "aeron:udp?control=localhost:10001|control-mode=response", + "aeron:ipc", + "aeron:ipc?control-mode=response" + }; + yield return new object[] { true, "aeron:ipc", null, null }; + yield return new object[] + { + true, "aeron:ipc", "aeron:udp?endpoint=localhost:8010", "aeron:udp?endpoint=localhost:0" + }; + yield return new object[] + { + true, + "aeron:ipc", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?control-mode=response|control=localhost:10002" + }; + yield return new object[] { true, "aeron:ipc", "aeron:ipc", "aeron:ipc" }; + yield return new object[] { true, "aeron:ipc", "aeron:ipc", "aeron:ipc?control-mode=response" }; + yield return new object[] { true, "aeron:ipc?control-mode=response", null, null }; + yield return new object[] + { + false, + "aeron:ipc?control-mode=response", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?endpoint=localhost:0" + }; + yield return new object[] + { + false, + "aeron:ipc?control-mode=response", + "aeron:udp?endpoint=localhost:8010", + "aeron:udp?control-mode=response|control=localhost:10002" + }; + yield return new object[] { true, "aeron:ipc?control-mode=response", "aeron:ipc", "aeron:ipc" }; + yield return new object[] + { + true, "aeron:ipc?control-mode=response", "aeron:ipc", "aeron:ipc?control-mode=response" + }; + } + } +} diff --git a/src/Adaptive.Archiver/Adaptive.Archiver.csproj b/src/Adaptive.Archiver/Adaptive.Archiver.csproj index c898c94c..e7b2b178 100644 --- a/src/Adaptive.Archiver/Adaptive.Archiver.csproj +++ b/src/Adaptive.Archiver/Adaptive.Archiver.csproj @@ -3,7 +3,7 @@ netstandard2.0 true Aeron.Archiver - 1.49.0 + 1.51.0 Adaptive Financial Consulting Ltd. Adaptive Financial Consulting Ltd. Archiving over the Aeron transport @@ -52,5 +52,6 @@ + \ No newline at end of file diff --git a/src/Adaptive.Archiver/AeronArchive.cs b/src/Adaptive.Archiver/AeronArchive.cs index bc5b68a8..e1d5ad7f 100644 --- a/src/Adaptive.Archiver/AeronArchive.cs +++ b/src/Adaptive.Archiver/AeronArchive.cs @@ -266,8 +266,6 @@ public static AeronArchive Connect(Context ctx) try { IIdleStrategy idleStrategy = ctx.IdleStrategy(); - AgentInvoker aeronClientInvoker = ctx.AeronClient().ConductorAgentInvoker; - AgentInvoker delegatingInvoker = ctx.AgentInvoker(); AsyncConnect.AsyncConnectState previousState = asyncConnect.State(); AeronArchive aeronArchive; @@ -282,16 +280,6 @@ public static AeronArchive Connect(Context ctx) idleStrategy.Reset(); previousState = asyncConnect.State(); } - - if (null != aeronClientInvoker) - { - aeronClientInvoker.Invoke(); - } - - if (null != delegatingInvoker) - { - delegatingInvoker.Invoke(); - } } return aeronArchive; @@ -299,7 +287,8 @@ public static AeronArchive Connect(Context ctx) catch (Exception ex) { Exception error = QuietClose(ex, asyncConnect); - throw error; + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error).Throw(); + throw; // unreachable } } @@ -868,7 +857,7 @@ public void StopRecording(string channel, int streamId) throw new ArchiveException("failed to send stop recording request"); } - PollForResponseAllowingError(_lastCorrelationId, ArchiveException.UNKNOWN_SUBSCRIPTION); + PollForResponse(_lastCorrelationId); } finally { @@ -2455,7 +2444,7 @@ public void UpdateChannel(long recordingId, string newChannel) if (!_archiveProxy.UpdateChannel(recordingId, newChannel, _lastCorrelationId, _controlSessionId)) { - throw new ArchiveException("failed to send migrate segments request"); + throw new ArchiveException("failed to send update channel request"); } PollForResponse(_lastCorrelationId); @@ -2471,7 +2460,8 @@ private void CheckDeadline(long deadlineNs, string errorMessage, long correlatio if (deadlineNs - _nanoClock.NanoTime() < 0) { throw new AeronTimeoutException( - errorMessage + " - correlationId=" + correlationId + " messageTimeout=" + _messageTimeoutNs + "ns", + errorMessage + " - correlationId=" + correlationId + " messageTimeout=" + + SystemUtil.FormatDuration(_messageTimeoutNs), Category.ERROR ); } @@ -2517,8 +2507,8 @@ private void PollNextResponse(long correlationId, long deadlineNs, ControlRespon CheckForDisconnect(subscription); CheckDeadline(deadlineNs, "awaiting response", correlationId); - _idleStrategy.Idle(); - InvokeInvokers(); + int workCount = InvokeInvokers(); + _idleStrategy.Idle(workCount); } } @@ -2666,7 +2656,7 @@ private int PollForDescriptors(long correlationId, int count, IRecordingDescript deadlineNs = _nanoClock.NanoTime() + _messageTimeoutNs; } - InvokeInvokers(); + int invokerWorkCount = InvokeInvokers(); if (fragments > 0) { @@ -2676,7 +2666,7 @@ private int PollForDescriptors(long correlationId, int count, IRecordingDescript CheckForDisconnect(poller.Subscription()); CheckDeadline(deadlineNs, "awaiting recording descriptors", correlationId); - _idleStrategy.Idle(); + _idleStrategy.Idle(invokerWorkCount); } } @@ -2708,7 +2698,7 @@ IRecordingSubscriptionDescriptorConsumer consumer deadlineNs = _nanoClock.NanoTime() + _messageTimeoutNs; } - InvokeInvokers(); + int invokerWorkCount = InvokeInvokers(); if (fragments > 0) { @@ -2718,7 +2708,7 @@ IRecordingSubscriptionDescriptorConsumer consumer CheckForDisconnect(poller.Subscription()); CheckDeadline(deadlineNs, "awaiting subscription descriptors", correlationId); - _idleStrategy.Idle(); + _idleStrategy.Idle(invokerWorkCount); } } @@ -2736,17 +2726,19 @@ private void DispatchRecordingSignal(ControlResponsePoller poller) ); } - private void InvokeInvokers() + private int InvokeInvokers() { + int workCount = 0; if (null != _aeronClientInvoker) { - _aeronClientInvoker.Invoke(); + workCount += _aeronClientInvoker.Invoke(); } if (null != _agentInvoker) { - _agentInvoker.Invoke(); + workCount += _agentInvoker.Invoke(); } + return workCount; } private void EnsureConnected() @@ -3007,7 +2999,11 @@ public static bool ControlTermBufferSparse() CONTROL_TERM_BUFFER_SPARSE_PROP_NAME, System.Convert.ToString(CONTROL_TERM_BUFFER_SPARSE_DEFAULT) ); - return "true".Equals(propValue); + // Match Java Boolean.parseBoolean — case-insensitive. .NET's Convert.ToString(true) + // returns "True" (capital T), so a case-sensitive "true".Equals would always be false + // when the property is not overridden — meaning the documented default of true never + // took effect. + return bool.TryParse(propValue, out var b) && b; } /// @@ -3125,7 +3121,8 @@ public static bool RecordingEventsEnabled() RECORDING_EVENTS_ENABLED_PROP_NAME, System.Convert.ToString(RECORDING_EVENTS_ENABLED_DEFAULT) ); - return "true".Equals(propValue); + // See note on ControlTermBufferSparse — case-insensitive to match Java parseBoolean. + return bool.TryParse(propValue, out var b) && b; } /// @@ -3220,6 +3217,7 @@ public void Conclude() .AeronDirectoryName(_aeronDirectoryName) .ClientName(string.IsNullOrEmpty(_clientName) ? "archive-client" : _clientName) .ErrorHandler(_errorHandler) + .SubscriberErrorHandler(RethrowingErrorHandler.INSTANCE) ); _ownsAeronClient = true; diff --git a/src/Adaptive.Archiver/ArchiveEvent.cs b/src/Adaptive.Archiver/ArchiveEvent.cs new file mode 100644 index 00000000..8bbf54bf --- /dev/null +++ b/src/Adaptive.Archiver/ArchiveEvent.cs @@ -0,0 +1,44 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Adaptive.Aeron.Exceptions; + +namespace Adaptive.Archiver +{ + /// + /// A means to capture an Archive event of significance that does not require a stack trace, + /// so it can be lighter-weight and take up less space in a + /// . + /// + public class ArchiveEvent : AeronEvent + { + /// + /// Archive event with provided message and . + /// + public ArchiveEvent(string message) + : base(message, Category.WARN) + { + } + + /// + /// Archive event with provided message and . + /// + public ArchiveEvent(string message, Category category) + : base(message, category) + { + } + } +} diff --git a/src/Adaptive.Archiver/ArchiveException.cs b/src/Adaptive.Archiver/ArchiveException.cs index 86ce730a..19208cf5 100644 --- a/src/Adaptive.Archiver/ArchiveException.cs +++ b/src/Adaptive.Archiver/ArchiveException.cs @@ -100,6 +100,55 @@ public class ArchiveException : AeronException /// public const short EMPTY_RECORDING = 15; + /// + /// The position specified for the operation is not valid, e.g. not frame-aligned, below start, or above stop. + /// + /// Since 1.51.0 + public const int INVALID_POSITION = 16; + + /// + /// Builds an error message for a replay with a start position beyond the limit. + /// + /// the recording id. + /// the replay position. + /// the limit position. + /// the created message. + /// Since 1.51.0 + public static string BuildReplayExceedsLimitErrorMsg( + long recordingId, long replayStartPosition, long limitPosition) + { + return "requested replay start position=" + replayStartPosition + + " must be less than the limit position=" + limitPosition + + " for recording " + recordingId; + } + + /// + /// Builds an error message for a replay with a position before the start position. + /// + /// the recording id. + /// the replay start position. + /// the start position of the recording. + /// the created message. + /// Since 1.51.0 + public static string BuildReplayBeforeStartErrorMsg( + long recordingId, long replayStartPosition, long startPosition) + { + return "requested replay start position=" + replayStartPosition + + " is less than recording start position=" + startPosition + + " for recording " + recordingId; + } + + /// + /// Builds an error message for an unknown recording. + /// + /// the recording id. + /// the created message. + /// Since 1.51.0 + public static string BuildUnknownRecordingErrorMsg(long recordingId) + { + return "unknown recording id: " + recordingId; + } + /// /// Error code providing more detail into what went wrong. /// @@ -231,6 +280,8 @@ public static string ErrorCodeAsString(int errorCode) return "UNAUTHORISED_ACTION"; case REPLICATION_CONNECTION_FAILURE: return "REPLICATION_CONNECTION_FAILURE"; + case INVALID_POSITION: + return "INVALID_POSITION"; default: return "unknown error code: " + errorCode; } diff --git a/src/Adaptive.Archiver/ArchiveProxy.cs b/src/Adaptive.Archiver/ArchiveProxy.cs index f2c40d1a..6551fefd 100644 --- a/src/Adaptive.Archiver/ArchiveProxy.cs +++ b/src/Adaptive.Archiver/ArchiveProxy.cs @@ -28,7 +28,8 @@ public class ArchiveProxy /// /// Default number of retry attempts to be made when offering requests. /// - public const int DEFAULT_RETRY_ATTEMPTS = 3; + /// + public const int DEFAULT_RETRY_ATTEMPTS = AeronArchive.Configuration.MESSAGE_RETRY_ATTEMPTS_DEFAULT; private readonly long _connectTimeoutNs; private readonly int _retryAttempts; diff --git a/src/Adaptive.Archiver/AsyncAeronArchive.cs b/src/Adaptive.Archiver/AsyncAeronArchive.cs new file mode 100644 index 00000000..d5c0884a --- /dev/null +++ b/src/Adaptive.Archiver/AsyncAeronArchive.cs @@ -0,0 +1,368 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using Adaptive.Aeron; +using Adaptive.Aeron.LogBuffer; +using Adaptive.Agrona; +using Adaptive.Archiver.Codecs; + +namespace Adaptive.Archiver +{ + /// + /// Internal wrapper around an and the resulting + /// that exposes a non-blocking state machine to a + /// . Control responses and recording descriptors are dispatched + /// to an . + /// + /// Since 1.51.0 + internal sealed class AsyncAeronArchive : IDisposable + { + private readonly ControlledFragmentAssembler _fragmentAssembler; + private readonly MessageHeaderDecoder _messageHeaderDecoder = new MessageHeaderDecoder(); + private readonly ControlResponseDecoder _controlResponseDecoder = new ControlResponseDecoder(); + private readonly RecordingDescriptorDecoder _recordingDescriptorDecoder = new RecordingDescriptorDecoder(); + + private readonly AeronArchive.Context _context; + private readonly IAsyncAeronArchiveListener _listener; + private State _state = State.CONNECTING; + private AeronArchive.AsyncConnect _asyncConnect; + private AeronArchive _aeronArchive; + private ArchiveProxy _archiveProxy; + private Subscription _subscription; + private long _controlSessionId; + + internal AsyncAeronArchive(AeronArchive.Context context, IAsyncAeronArchiveListener listener) + { + _context = context; + _listener = listener; + _fragmentAssembler = new ControlledFragmentAssembler(new ControlledFragmentHandlerDelegate(OnFragment)); + } + + internal bool TrySendListRecordingRequest(long correlationId, long recordingId) + { + if (_state == State.CONNECTED) + { + try + { + return _archiveProxy.ListRecording(recordingId, correlationId, _controlSessionId); + } + catch (ArchiveException) + { + _state = State.DISCONNECTED; + } + } + + return false; + } + + internal bool TrySendMaxRecordedPositionRequest(long correlationId, long recordingId) + { + if (_state == State.CONNECTED) + { + try + { + return _archiveProxy.GetMaxRecordedPosition(recordingId, correlationId, _controlSessionId); + } + catch (ArchiveException) + { + _state = State.DISCONNECTED; + } + } + + return false; + } + + internal bool TrySendReplayTokenRequest(long correlationId, long recordingId) + { + if (_state == State.CONNECTED) + { + try + { + return _archiveProxy.RequestReplayToken(correlationId, _controlSessionId, recordingId); + } + catch (ArchiveException) + { + _state = State.DISCONNECTED; + } + } + + return false; + } + + internal bool TrySendReplayRequest( + long correlationId, + long recordingId, + int replayStreamId, + string replayChannel, + ReplayParams replayParams) + { + return TrySendReplayRequest( + _archiveProxy, correlationId, recordingId, replayStreamId, replayChannel, replayParams); + } + + internal bool TrySendReplayRequest( + ArchiveProxy archiveProxy, + long correlationId, + long recordingId, + int replayStreamId, + string replayChannel, + ReplayParams replayParams) + { + if (_state == State.CONNECTED) + { + try + { + return archiveProxy.Replay( + recordingId, + replayChannel, + replayStreamId, + replayParams, + correlationId, + _controlSessionId); + } + catch (ArchiveException) + { + _state = State.DISCONNECTED; + } + } + + return false; + } + + internal bool TrySendStopReplayRequest(long correlationId, long replaySessionId) + { + if (_state == State.CONNECTED) + { + try + { + return _archiveProxy.StopReplay(replaySessionId, correlationId, _controlSessionId); + } + catch (ArchiveException) + { + _state = State.DISCONNECTED; + } + } + + return false; + } + + internal int Poll() + { + switch (_state) + { + case State.CONNECTING: + return Connecting(); + case State.CONNECTED: + return Connected(); + case State.DISCONNECTED: + return Disconnected(); + case State.CLOSED: + default: + return 0; + } + } + + private int Connecting() + { + int workCount = 0; + + AeronArchive.AsyncConnect asyncConnect = _asyncConnect; + + if (asyncConnect == null) + { + try + { + asyncConnect = AeronArchive.ConnectAsync(_context.Clone()); + } + catch (Exception e) + { + _state = State.CLOSED; + _listener.OnError(e); + return 1; + } + + _asyncConnect = asyncConnect; + workCount++; + } + + AeronArchive aeronArchive = null; + try + { + int stepBefore = asyncConnect.Step(); + aeronArchive = asyncConnect.Poll(); + workCount += asyncConnect.Step() - stepBefore; + } + catch (Exception e) + { + _asyncConnect = null; + CloseHelper.QuietDispose(asyncConnect); + _listener.OnError(e); + workCount++; + } + + if (aeronArchive != null) + { + _asyncConnect = null; + _aeronArchive = aeronArchive; + _archiveProxy = aeronArchive.Proxy(); + _subscription = aeronArchive.ControlResponsePoller().Subscription(); + _controlSessionId = aeronArchive.ControlSessionId(); + _state = State.CONNECTED; + _listener.OnConnected(); + } + + return workCount; + } + + private int Connected() + { + if (!_subscription.IsConnected) + { + _state = State.DISCONNECTED; + return 1; + } + + return _subscription.ControlledPoll(_fragmentAssembler, ControlResponsePoller.FRAGMENT_LIMIT); + } + + private int Disconnected() + { + CloseHelper.QuietDispose(_aeronArchive); + + _subscription = null; + _archiveProxy = null; + _aeronArchive = null; + + try + { + _listener.OnDisconnected(); + } + finally + { + _state = State.CONNECTING; + } + + return 1; + } + + internal bool IsConnected => _state == State.CONNECTED; + + internal bool IsClosed => _state == State.CLOSED; + + public void Dispose() + { + if (_state != State.CLOSED) + { + _state = State.CLOSED; + + CloseHelper.QuietDispose(_asyncConnect); + CloseHelper.QuietDispose(_aeronArchive); + } + } + + private ControlledFragmentHandlerAction OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + _messageHeaderDecoder.Wrap(buffer, offset); + + int schemaId = _messageHeaderDecoder.SchemaId(); + if (schemaId != MessageHeaderDecoder.SCHEMA_ID) + { + throw new ArchiveException( + "expected schemaId=" + MessageHeaderDecoder.SCHEMA_ID + ", actual=" + schemaId); + } + + int templateId = _messageHeaderDecoder.TemplateId(); + switch (templateId) + { + case ControlResponseDecoder.TEMPLATE_ID: + _controlResponseDecoder.Wrap( + buffer, + offset + MessageHeaderEncoder.ENCODED_LENGTH, + _messageHeaderDecoder.BlockLength(), + _messageHeaderDecoder.Version()); + + if (_controlResponseDecoder.ControlSessionId() == _controlSessionId) + { + _listener.OnControlResponse( + _controlResponseDecoder.CorrelationId(), + _controlResponseDecoder.RelevantId(), + _controlResponseDecoder.Code(), + _controlResponseDecoder.ErrorMessage()); + } + + break; + + case RecordingDescriptorDecoder.TEMPLATE_ID: + _recordingDescriptorDecoder.Wrap( + buffer, + offset + MessageHeaderEncoder.ENCODED_LENGTH, + _messageHeaderDecoder.BlockLength(), + _messageHeaderDecoder.Version()); + + if (_recordingDescriptorDecoder.ControlSessionId() == _controlSessionId) + { + _listener.OnRecordingDescriptor( + _controlSessionId, + _recordingDescriptorDecoder.CorrelationId(), + _recordingDescriptorDecoder.RecordingId(), + _recordingDescriptorDecoder.StartTimestamp(), + _recordingDescriptorDecoder.StopTimestamp(), + _recordingDescriptorDecoder.StartPosition(), + _recordingDescriptorDecoder.StopPosition(), + _recordingDescriptorDecoder.InitialTermId(), + _recordingDescriptorDecoder.SegmentFileLength(), + _recordingDescriptorDecoder.TermBufferLength(), + _recordingDescriptorDecoder.MtuLength(), + _recordingDescriptorDecoder.SessionId(), + _recordingDescriptorDecoder.StreamId(), + _recordingDescriptorDecoder.StrippedChannel(), + _recordingDescriptorDecoder.OriginalChannel(), + _recordingDescriptorDecoder.SourceIdentity()); + } + + break; + } + + return ControlledFragmentHandlerAction.CONTINUE; + } + + private sealed class ControlledFragmentHandlerDelegate : IControlledFragmentHandler + { + private readonly Func _handler; + + internal ControlledFragmentHandlerDelegate( + Func handler) + { + _handler = handler; + } + + public ControlledFragmentHandlerAction OnFragment( + IDirectBuffer buffer, int offset, int length, Header header) + { + return _handler(buffer, offset, length, header); + } + } + + private enum State + { + CONNECTING, + CONNECTED, + DISCONNECTED, + CLOSED + } + } +} diff --git a/src/Adaptive.Archiver/IAsyncAeronArchiveListener.cs b/src/Adaptive.Archiver/IAsyncAeronArchiveListener.cs new file mode 100644 index 00000000..cf8c25c7 --- /dev/null +++ b/src/Adaptive.Archiver/IAsyncAeronArchiveListener.cs @@ -0,0 +1,37 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using Adaptive.Archiver.Codecs; + +namespace Adaptive.Archiver +{ + /// + /// Internal listener used by to deliver control responses and recording + /// descriptors to the owning . + /// + /// Since 1.51.0 + internal interface IAsyncAeronArchiveListener : IRecordingDescriptorConsumer + { + void OnConnected(); + + void OnDisconnected(); + + void OnControlResponse(long correlationId, long relevantId, ControlResponseCode code, string errorMessage); + + void OnError(Exception error); + } +} diff --git a/src/Adaptive.Archiver/IPersistentSubscriptionListener.cs b/src/Adaptive.Archiver/IPersistentSubscriptionListener.cs new file mode 100644 index 00000000..3e98133f --- /dev/null +++ b/src/Adaptive.Archiver/IPersistentSubscriptionListener.cs @@ -0,0 +1,47 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; + +namespace Adaptive.Archiver +{ + /// + /// Interface for delivering notifications about changes in state of a . + /// + /// Since 1.51.0 + public interface IPersistentSubscriptionListener + { + /// + /// Called when the transitions to consuming from the live channel. + /// + void OnLiveJoined(); + + /// + /// Called when the stops consuming from the live channel. + /// + void OnLiveLeft(); + + /// + /// Called when the encounters an error. + /// + /// This will be called for both terminal and non-terminal errors. + /// + /// + /// the exception that caused the error. + /// + void OnError(Exception e); + } +} diff --git a/src/Adaptive.Archiver/PersistentSubscription.cs b/src/Adaptive.Archiver/PersistentSubscription.cs new file mode 100644 index 00000000..e9507743 --- /dev/null +++ b/src/Adaptive.Archiver/PersistentSubscription.cs @@ -0,0 +1,2177 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Diagnostics; +using System.Threading; +using Adaptive.Aeron; +using Adaptive.Aeron.Exceptions; +using Adaptive.Aeron.LogBuffer; +using Adaptive.Agrona; +using Adaptive.Agrona.Concurrent; +using Adaptive.Archiver.Codecs; +using AeronClient = Adaptive.Aeron.Aeron; + +namespace Adaptive.Archiver +{ + /// + /// A PersistentSubscription allows the consumption of messages from a live publication that is also being + /// recorded to an Archive, in order and without gaps, regardless of when the messages were published. + /// It tries to read messages from the live subscription as much as possible, falling back to an Archive replay when + /// necessary, making any source switches transparent to the application. + /// + /// It offers: + /// + /// late join - if any messages have been published after the provided start position, they will be replayed + /// from the recording before switching to the live subscription, + /// seamless recovery - if a subscription gets disconnected due to flow control or a network issue, it will + /// automatically recover and replay any missed messages. + /// + /// + /// Not thread-safe. Must be polled in a duty cycle. Performs message reassembly. + /// + /// Since 1.51.0 + public sealed class PersistentSubscription : IDisposable + { + /// + /// Special value for which will make a PersistentSubscription + /// start by replaying the recording from its start position. Used when an application needs to process all + /// historical and future messages. + /// + public const long FROM_START = AeronArchive.NULL_POSITION; + + /// + /// Special value for which will make a PersistentSubscription + /// start by joining the live subscription. Used when an application needs to process all future messages from + /// an unspecified join position, but does not need any historical ones. + /// + public const long FROM_LIVE = -2; + + private readonly ImageControlledFragmentAssembler _controlledFragmentAssembler; + private readonly ImageFragmentAssembler _uncontrolledFragmentAssembler; + private readonly IControlledFragmentHandler _liveCatchupFragmentHandler; + private readonly IControlledFragmentHandler _replayCatchupControlledFragmentHandler; + private readonly IControlledFragmentHandler _replayCatchupUncontrolledFragmentHandler; + private readonly ListRecordingRequest _listRecordingRequest = new ListRecordingRequest(); + private readonly MaxRecordedPosition _maxRecordedPosition; + private readonly AsyncArchiveOp _replayRequest = new AsyncArchiveOp(); + private readonly AsyncArchiveOp _replayTokenRequest = new AsyncArchiveOp(); + private readonly ReplayParams _replayParams = new ReplayParams(); + private readonly Context _ctx; + private readonly long _recordingId; + private readonly IPersistentSubscriptionListener _listener; + private readonly string _liveChannel; + private readonly int _liveStreamId; + private readonly string _replayChannel; + private readonly ChannelUri _replayChannelUri; + private readonly ReplayChannelType _replayChannelType; + private readonly int _replayStreamId; + private readonly AeronClient _aeron; + private readonly INanoClock _nanoClock; + private readonly AsyncAeronArchive _asyncAeronArchive; + private readonly long _messageTimeoutNs; + private readonly Counter _stateCounter; + private readonly Counter _joinDifferenceCounter; + private readonly Counter _liveLeftCounter; + private readonly Counter _liveJoinedCounter; + + private State _state; + private long _replaySessionId = AeronClient.NULL_VALUE; + private long _replaySubscriptionId = AeronClient.NULL_VALUE; + private Subscription _replaySubscription; + private long _replayImageDeadline; + private Image _replayImage; + private long _requestPublicationId; + private ExclusivePublication _requestPublication; + private ArchiveProxy _responseChannelArchiveProxy; + private long _replayToken = AeronClient.NULL_VALUE; + private long _liveSubscriptionId = AeronClient.NULL_VALUE; + private Subscription _liveSubscription; + private long _liveImageDeadline; + private bool _liveImageDeadlineBreached; + private Image _liveImage; + private IControlledFragmentHandler _controlledFragmentHandler; + private IFragmentHandler _uncontrolledFragmentHandler; + private long _joinDifference; + private long _nextLivePosition = AeronClient.NULL_VALUE; + private long _position; + private long _lastObservedLivePosition = AeronClient.NULL_VALUE; + private Exception _failureReason; + + private PersistentSubscription(Context ctx) + { + ctx.Conclude(); + + _ctx = ctx; + _recordingId = ctx.RecordingId(); + _liveChannel = ctx.LiveChannel(); + _liveStreamId = ctx.LiveStreamId(); + _replayChannel = ctx.ReplayChannel(); + _replayChannelUri = ChannelUri.Parse(_replayChannel); + _replayChannelType = ReplayChannelTypeHelper.Of(_replayChannelUri); + _replayStreamId = ctx.ReplayStreamId(); + _listener = ctx.Listener(); + _aeron = ctx.Aeron(); + _nanoClock = _aeron.Ctx.NanoClock(); + _asyncAeronArchive = new AsyncAeronArchive(ctx.AeronArchiveContext(), new ArchiveListener(this)); + _messageTimeoutNs = ctx.AeronArchiveContext().MessageTimeoutNs(); + _stateCounter = ctx.StateCounter(); + _joinDifferenceCounter = ctx.JoinDifferenceCounter(); + _liveLeftCounter = ctx.LiveLeftCounter(); + _liveJoinedCounter = ctx.LiveJoinedCounter(); + _position = ctx.StartPosition(); + + _controlledFragmentAssembler = new ImageControlledFragmentAssembler( + new InlineControlledFragmentHandler(OnFragmentControlled)); + _uncontrolledFragmentAssembler = new ImageFragmentAssembler( + new InlineFragmentHandler(OnFragmentUncontrolled)); + _liveCatchupFragmentHandler = new InlineControlledFragmentHandler(OnLiveCatchupFragment); + _replayCatchupControlledFragmentHandler = + new InlineControlledFragmentHandler(OnReplayCatchupFragmentControlled); + _replayCatchupUncontrolledFragmentHandler = + new InlineControlledFragmentHandler(OnReplayCatchupFragmentUncontrolled); + _maxRecordedPosition = new MaxRecordedPosition(this); + + UpdateJoinDifference(long.MinValue); + + _state = State.AWAIT_ARCHIVE_CONNECTION; + + if (!_stateCounter.IsClosed) + { + _stateCounter.SetRelease((int)_state); + } + } + + /// + /// Creates a new PersistentSubscription using the given configuration. The returned instance must be + /// polled to perform any work. + /// + /// the configuration to use for the new PersistentSubscription. + /// a new PersistentSubscription. + /// + /// + public static PersistentSubscription Create(Context ctx) + { + return new PersistentSubscription(ctx); + } + + /// + /// Poll for the next available message(s). The handler receives fully assembled messages. + /// + /// Either this method or must be called in a duty + /// cycle for the PersistentSubscription to perform its work. + /// + /// the handler to receive assembled messages if any are available. + /// the max number of fragments to be processed during the poll operation. + /// positive number if work has been done, 0 otherwise. + public int Poll(IFragmentHandler fragmentHandler, int fragmentLimit) + { + try + { + _uncontrolledFragmentHandler = fragmentHandler; + return DoWork(fragmentLimit, false); + } + finally + { + _uncontrolledFragmentHandler = null; + } + } + + /// + /// Poll for the next available message(s), allowing the to control + /// whether polling continues. + /// + /// the handler to receive assembled messages if any are available. + /// the max number of fragments to be processed during the poll operation. + /// positive number if work has been done, 0 otherwise. + public int ControlledPoll(IControlledFragmentHandler fragmentHandler, int fragmentLimit) + { + try + { + _controlledFragmentHandler = fragmentHandler; + return DoWork(fragmentLimit, true); + } + finally + { + _controlledFragmentHandler = null; + } + } + + private int DoWork(int fragmentLimit, bool isControlled) + { + int workCount = 0; + AgentInvoker agentInvoker = _aeron.ConductorAgentInvoker; + if (null != agentInvoker) + { + workCount += agentInvoker.Invoke(); + } + workCount += _asyncAeronArchive.Poll(); + + switch (_state) + { + case State.AWAIT_ARCHIVE_CONNECTION: + workCount += AwaitArchiveConnection(); + break; + case State.SEND_LIST_RECORDING_REQUEST: + workCount += SendListRecordingRequest(); + break; + case State.AWAIT_LIST_RECORDING_RESPONSE: + workCount += AwaitListRecordingResponse(); + break; + case State.SEND_REPLAY_REQUEST: + workCount += SendReplayRequest(); + break; + case State.AWAIT_REPLAY_RESPONSE: + workCount += AwaitReplayResponse(); + break; + case State.ADD_REPLAY_SUBSCRIPTION: + workCount += AddReplaySubscription(); + break; + case State.AWAIT_REPLAY_SUBSCRIPTION: + workCount += AwaitReplaySubscription(); + break; + case State.AWAIT_REPLAY_CHANNEL_ENDPOINT: + workCount += AwaitReplayChannelEndpoint(); + break; + case State.ADD_REQUEST_PUBLICATION: + workCount += AddRequestPublication(); + break; + case State.AWAIT_REQUEST_PUBLICATION: + workCount += AwaitRequestPublication(); + break; + case State.SEND_REPLAY_TOKEN_REQUEST: + workCount += SendReplayTokenRequest(); + break; + case State.AWAIT_REPLAY_TOKEN: + workCount += AwaitReplayToken(); + break; + case State.REPLAY: + workCount += Replay(fragmentLimit, isControlled); + break; + case State.ATTEMPT_SWITCH: + workCount += AttemptSwitch(fragmentLimit, isControlled); + break; + case State.ADD_LIVE_SUBSCRIPTION: + workCount += AddLiveSubscription(); + break; + case State.AWAIT_LIVE: + workCount += AwaitLive(); + break; + case State.LIVE: + workCount += Live(fragmentLimit, isControlled); + break; + case State.FAILED: + break; + } + + return workCount; + } + + /// + /// True if the persistent subscription is reading from the live stream. + /// + public bool IsLive => State.LIVE == _state; + + /// + /// True if the persistent subscription is replaying from a recording. + /// + public bool IsReplaying => State.REPLAY == _state || State.ATTEMPT_SWITCH == _state; + + /// + /// True if the persistent subscription has failed. + /// + public bool HasFailed => State.FAILED == _state; + + /// + /// The terminal error that caused the persistent subscription to fail, or null if not in the + /// failed state. Only meaningful when is true. + /// + public Exception FailureReason => _failureReason; + + /// + /// Close this PersistentSubscription and release any resources owned by it. + /// + public void Dispose() + { + CloseHelper.QuietDispose(CloseLive); + CloseHelper.QuietDispose(CloseReplay); + CloseHelper.QuietDispose(_asyncAeronArchive); + CloseHelper.QuietDispose(_ctx.Dispose); + } + + private void CloseLive() + { + if (!_ctx.OwnsAeronClient()) + { + if (AeronClient.NULL_VALUE != _liveSubscriptionId) + { + _aeron.AsyncRemoveSubscription(_liveSubscriptionId); + } + + if (null != _liveSubscription) + { + _liveSubscription.Dispose(); + } + } + } + + private void CloseReplay() + { + CleanUpReplay(); + + if (!_ctx.OwnsAeronClient()) + { + if (AeronClient.NULL_VALUE != _requestPublicationId) + { + _aeron.AsyncRemovePublication(_requestPublicationId); + } + + if (null != _requestPublication) + { + _requestPublication.Dispose(); + } + + if (AeronClient.NULL_VALUE != _replaySubscriptionId) + { + _aeron.AsyncRemoveSubscription(_replaySubscriptionId); + } + + if (null != _replaySubscription) + { + _replaySubscription.Dispose(); + } + } + } + + internal long JoinDifference => _joinDifference; + + private void UpdateJoinDifference(long joinDifference) + { + _joinDifference = joinDifference; + + if (!_joinDifferenceCounter.IsClosed) + { + _joinDifferenceCounter.SetRelease(joinDifference); + } + } + + private int AwaitArchiveConnection() + { + if (!_asyncAeronArchive.IsConnected) + { + return 0; + } + + SetState(State.SEND_LIST_RECORDING_REQUEST); + + return 1; + } + + private int SendListRecordingRequest() + { + long correlationId = _aeron.NextCorrelationId(); + + if (!_asyncAeronArchive.TrySendListRecordingRequest(correlationId, _recordingId)) + { + if (_asyncAeronArchive.IsConnected) + { + return 0; + } + else + { + SetState(State.AWAIT_ARCHIVE_CONNECTION); + + return 1; + } + } + + _listRecordingRequest.Init(correlationId, _nanoClock.NanoTime() + _messageTimeoutNs); + _listRecordingRequest.Remaining = 1; + + SetState(State.AWAIT_LIST_RECORDING_RESPONSE); + + return 1; + } + + private int AwaitListRecordingResponse() + { + if (!_listRecordingRequest.ResponseReceived) + { + if (_nanoClock.NanoTime() - _listRecordingRequest.DeadlineNs >= 0) + { + SetState(_asyncAeronArchive.IsConnected + ? State.SEND_LIST_RECORDING_REQUEST + : State.AWAIT_ARCHIVE_CONNECTION); + + return 1; + } + + return 0; + } + + PersistentSubscriptionException error = ValidateDescriptor(); + + if (null != error) + { + SetState(State.FAILED); + OnTerminalError(error); + } + else + { + if (FROM_LIVE == _position || + (AeronArchive.NULL_POSITION != _listRecordingRequest.StopPosition && + _position == _listRecordingRequest.StopPosition)) + { + SetState(State.ADD_LIVE_SUBSCRIPTION); + } + else + { + SetUpReplay(); + } + } + + return 1; + } + + private PersistentSubscriptionException ValidateDescriptor() + { + if (0 == _listRecordingRequest.Remaining) + { + Debug.Assert( + _listRecordingRequest.RecordingId == _recordingId, + _listRecordingRequest.ToString()); + + if (_liveStreamId != _listRecordingRequest.StreamId) + { + return new PersistentSubscriptionException( + PersistentSubscriptionException.Reason.STREAM_ID_MISMATCH, + "Requested live stream with ID: " + _liveStreamId + " does not match stream ID: " + + _listRecordingRequest.StreamId + " for recording: " + _recordingId); + } + + if (AeronArchive.NULL_POSITION != _listRecordingRequest.StopPosition && + _lastObservedLivePosition > _listRecordingRequest.StopPosition) + { + return new PersistentSubscriptionException( + PersistentSubscriptionException.Reason.INVALID_START_POSITION, + "recording " + _recordingId + " stopped at position=" + + _listRecordingRequest.StopPosition + + " which is earlier than last observed live position=" + + _lastObservedLivePosition); + } + + if (_position >= 0) + { + if (_position < _listRecordingRequest.StartPosition) + { + return new PersistentSubscriptionException( + PersistentSubscriptionException.Reason.INVALID_START_POSITION, + ArchiveException.BuildReplayBeforeStartErrorMsg( + _recordingId, _position, _listRecordingRequest.StartPosition)); + } + + if (AeronArchive.NULL_POSITION != _listRecordingRequest.StopPosition && + _position > _listRecordingRequest.StopPosition) + { + return new PersistentSubscriptionException( + PersistentSubscriptionException.Reason.INVALID_START_POSITION, + ArchiveException.BuildReplayExceedsLimitErrorMsg( + _recordingId, _position, _listRecordingRequest.StopPosition)); + } + } + else if (FROM_START == _position) + { + _position = _listRecordingRequest.StartPosition; + } + } + else + { + Debug.Assert( + 1 == _listRecordingRequest.Remaining && + ControlResponseCode.RECORDING_UNKNOWN == _listRecordingRequest.Code && + _listRecordingRequest.RelevantId == _recordingId, + _listRecordingRequest.ToString()); + + return new PersistentSubscriptionException( + PersistentSubscriptionException.Reason.RECORDING_NOT_FOUND, + ArchiveException.BuildUnknownRecordingErrorMsg(_recordingId)); + } + + return null; + } + + private void SetUpReplay() + { + ResetReplayCatchupState(); + + switch (_replayChannelType) + { + case ReplayChannelType.SESSION_SPECIFIC: + SetState(State.SEND_REPLAY_REQUEST); + break; + case ReplayChannelType.DYNAMIC_PORT: + case ReplayChannelType.RESPONSE_CHANNEL: + SetState(State.ADD_REPLAY_SUBSCRIPTION); + break; + } + } + + private void RefreshRecordingDescriptor() + { + ResetReplayCatchupState(); + + SetState(State.SEND_LIST_RECORDING_REQUEST); + } + + private void ResetReplayCatchupState() + { + UpdateJoinDifference(long.MinValue); + _maxRecordedPosition.Reset(_listRecordingRequest.TermBufferLength >> 2); + _nextLivePosition = AeronClient.NULL_VALUE; + } + + private void CleanUpReplay() + { + if (AeronClient.NULL_VALUE != _replaySessionId) + { + _asyncAeronArchive.TrySendStopReplayRequest(_aeron.NextCorrelationId(), _replaySessionId); + + _replaySessionId = AeronClient.NULL_VALUE; + } + } + + private void CleanUpReplaySubscription() + { + if (AeronClient.NULL_VALUE != _replaySubscriptionId) + { + _aeron.AsyncRemoveSubscription(_replaySubscriptionId); + } + + if (null != _replaySubscription) + { + _aeron.AsyncRemoveSubscription(_replaySubscription.RegistrationId); + } + + _replaySubscriptionId = AeronClient.NULL_VALUE; + _replaySubscription = null; + _replayImage = null; + } + + private void CleanUpRequestPublication() + { + if (AeronClient.NULL_VALUE != _requestPublicationId) + { + _aeron.AsyncRemovePublication(_requestPublicationId); + } + + if (null != _requestPublication) + { + _aeron.AsyncRemovePublication(_requestPublication.RegistrationId); + } + + _requestPublicationId = AeronClient.NULL_VALUE; + _requestPublication = null; + _responseChannelArchiveProxy = null; + } + + private void CleanUpLiveSubscription() + { + if (AeronClient.NULL_VALUE != _liveSubscriptionId) + { + _aeron.AsyncRemoveSubscription(_liveSubscriptionId); + } + + if (null != _liveSubscription) + { + _aeron.AsyncRemoveSubscription(_liveSubscription.RegistrationId); + } + + _liveSubscriptionId = AeronClient.NULL_VALUE; + _liveSubscription = null; + _liveImage = null; + } + + private int SendReplayRequest() + { + long correlationId = _aeron.NextCorrelationId(); + + string channel; + switch (_replayChannelType) + { + case ReplayChannelType.SESSION_SPECIFIC: + channel = _replayChannel; + break; + case ReplayChannelType.DYNAMIC_PORT: + case ReplayChannelType.RESPONSE_CHANNEL: + channel = _replayChannelUri.ToString(); + break; + default: + channel = _replayChannel; + break; + } + + _replayParams.Reset(); + _replayParams.Position(_position).Length(AeronArchive.REPLAY_ALL_AND_FOLLOW); + bool result; + if (ReplayChannelType.RESPONSE_CHANNEL == _replayChannelType) + { + _replayParams.ReplayToken(_replayToken); + + result = _asyncAeronArchive.TrySendReplayRequest( + _responseChannelArchiveProxy, + correlationId, + _recordingId, + _replayStreamId, + channel, + _replayParams); + } + else + { + result = _asyncAeronArchive.TrySendReplayRequest( + correlationId, + _recordingId, + _replayStreamId, + channel, + _replayParams); + } + + if (!result) + { + if (_asyncAeronArchive.IsConnected) + { + return 0; + } + else + { + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + + SetState(State.AWAIT_ARCHIVE_CONNECTION); + + return 1; + } + } + + _replayRequest.Init(correlationId, _nanoClock.NanoTime() + _messageTimeoutNs); + + SetState(State.AWAIT_REPLAY_RESPONSE); + + return 1; + } + + private int AwaitReplayResponse() + { + if (!_replayRequest.ResponseReceived) + { + if (_nanoClock.NanoTime() - _replayRequest.DeadlineNs >= 0) + { + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + if (_asyncAeronArchive.IsConnected) + { + SetUpReplay(); + } + else + { + SetState(State.AWAIT_ARCHIVE_CONNECTION); + } + + return 1; + } + + return 0; + } + + if (ControlResponseCode.OK != _replayRequest.Code) + { + SetState(State.FAILED); + + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + + int errorCode = (int)_replayRequest.RelevantId; + + PersistentSubscriptionException.Reason reason; + if (ArchiveException.INVALID_POSITION == errorCode) + { + reason = PersistentSubscriptionException.Reason.INVALID_START_POSITION; + } + else if (ArchiveException.UNKNOWN_RECORDING == errorCode) + { + reason = PersistentSubscriptionException.Reason.RECORDING_NOT_FOUND; + } + else + { + reason = PersistentSubscriptionException.Reason.GENERIC; + } + + OnTerminalError(new PersistentSubscriptionException( + reason, "replay request failed: " + _replayRequest.ErrorMessage)); + + return 1; + } + + _replaySessionId = _replayRequest.RelevantId; + + switch (_replayChannelType) + { + case ReplayChannelType.SESSION_SPECIFIC: + _replayChannelUri.Put( + AeronClient.Context.SESSION_ID_PARAM_NAME, + ((int)_replaySessionId).ToString()); + SetState(State.ADD_REPLAY_SUBSCRIPTION); + return 1; + case ReplayChannelType.DYNAMIC_PORT: + _replayImageDeadline = _nanoClock.NanoTime() + _messageTimeoutNs; + SetState(State.REPLAY); + return 1; + case ReplayChannelType.RESPONSE_CHANNEL: + CleanUpRequestPublication(); + _replayImageDeadline = _nanoClock.NanoTime() + _messageTimeoutNs; + SetState(State.REPLAY); + return 1; + default: + return 1; + } + } + + private int AddReplaySubscription() + { + string channel; + switch (_replayChannelType) + { + case ReplayChannelType.SESSION_SPECIFIC: + channel = _replayChannelUri.ToString(); + break; + case ReplayChannelType.DYNAMIC_PORT: + case ReplayChannelType.RESPONSE_CHANNEL: + channel = _replayChannel; + break; + default: + channel = _replayChannel; + break; + } + + _replaySubscriptionId = _aeron.AsyncAddSubscription(channel, _replayStreamId); + + SetState(State.AWAIT_REPLAY_SUBSCRIPTION); + + return 1; + } + + private int AwaitReplaySubscription() + { + Subscription subscription; + try + { + subscription = _aeron.GetSubscription(_replaySubscriptionId); + } + catch (RegistrationException e) + { + _replaySubscriptionId = AeronClient.NULL_VALUE; + + CleanUpReplay(); + + if (ErrorCode.RESOURCE_TEMPORARILY_UNAVAILABLE == e.ErrorCode()) + { + SetUpReplay(); + _listener.OnError(e); + } + else + { + SetState(State.FAILED); + OnTerminalError(e); + } + + return 1; + } + + if (null == subscription) + { + return 0; + } + + _replaySubscriptionId = AeronClient.NULL_VALUE; + _replaySubscription = subscription; + + if (ReplayChannelType.SESSION_SPECIFIC == _replayChannelType) + { + _replayImageDeadline = _nanoClock.NanoTime() + _messageTimeoutNs; + } + + switch (_replayChannelType) + { + case ReplayChannelType.SESSION_SPECIFIC: + SetState(State.REPLAY); + break; + case ReplayChannelType.DYNAMIC_PORT: + SetState(State.AWAIT_REPLAY_CHANNEL_ENDPOINT); + break; + case ReplayChannelType.RESPONSE_CHANNEL: + SetState(State.ADD_REQUEST_PUBLICATION); + break; + } + + return 1; + } + + private int AwaitReplayChannelEndpoint() + { + string resolvedChannel = _replaySubscription.TryResolveChannelEndpointPort(); + if (null == resolvedChannel) + { + return 0; + } + + string resolvedEndpoint = ChannelUri.Parse(resolvedChannel).Get(AeronClient.Context.ENDPOINT_PARAM_NAME); + if (null != resolvedEndpoint) + { + _replayChannelUri.Put(AeronClient.Context.ENDPOINT_PARAM_NAME, resolvedEndpoint); + } + + SetState(State.SEND_REPLAY_REQUEST); + + return 1; + } + + private int AddRequestPublication() + { + string controlRequestChannel = _ctx.AeronArchiveContext().ControlRequestChannel(); + int controlRequestStreamId = _ctx.AeronArchiveContext().ControlRequestStreamId(); + int controlTermBufferLength = _ctx.AeronArchiveContext().ControlTermBufferLength(); + ChannelUriStringBuilder uriBuilder = new ChannelUriStringBuilder(controlRequestChannel) + .SessionId((int?)null) + .ResponseCorrelationId(_replaySubscription.RegistrationId) + .TermId((int?)null) + .InitialTermId((int?)null) + .TermOffset((int?)null) + .TermLength(controlTermBufferLength) + .SpiesSimulateConnection(false); + string requestPublicationChannel = uriBuilder.Build(); + + _requestPublicationId = _aeron.AsyncAddExclusivePublication( + requestPublicationChannel, controlRequestStreamId); + SetState(State.AWAIT_REQUEST_PUBLICATION); + return 1; + } + + private int AwaitRequestPublication() + { + ExclusivePublication publication; + try + { + publication = _aeron.GetExclusivePublication(_requestPublicationId); + } + catch (RegistrationException e) + { + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + + if (ErrorCode.RESOURCE_TEMPORARILY_UNAVAILABLE == e.ErrorCode()) + { + SetUpReplay(); + _listener.OnError(e); + } + else + { + SetState(State.FAILED); + OnTerminalError(e); + } + + return 1; + } + + if (null == publication) + { + return 0; + } + + _requestPublicationId = AeronClient.NULL_VALUE; + _requestPublication = publication; + _responseChannelArchiveProxy = new ArchiveProxy(publication); + + SetState(State.SEND_REPLAY_TOKEN_REQUEST); + + return 1; + } + + private int SendReplayTokenRequest() + { + long correlationId = _aeron.NextCorrelationId(); + + if (!_asyncAeronArchive.TrySendReplayTokenRequest(correlationId, _recordingId)) + { + if (_asyncAeronArchive.IsConnected) + { + return 0; + } + else + { + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + + SetState(State.AWAIT_ARCHIVE_CONNECTION); + + return 1; + } + } + + _replayTokenRequest.Init(correlationId, _nanoClock.NanoTime() + _messageTimeoutNs); + + SetState(State.AWAIT_REPLAY_TOKEN); + + return 1; + } + + private int AwaitReplayToken() + { + if (!_replayTokenRequest.ResponseReceived) + { + if (_nanoClock.NanoTime() - _replayTokenRequest.DeadlineNs >= 0) + { + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + + if (_asyncAeronArchive.IsConnected) + { + SetUpReplay(); + } + else + { + SetState(State.AWAIT_ARCHIVE_CONNECTION); + } + + return 1; + } + + return 0; + } + + if (ControlResponseCode.OK != _replayTokenRequest.Code) + { + SetState(State.FAILED); + + CleanUpRequestPublication(); + CleanUpReplaySubscription(); + + OnTerminalError(new ArchiveException( + "replay token request failed: " + _replayTokenRequest.ErrorMessage, + (int)_replayTokenRequest.RelevantId, + _replayTokenRequest.CorrelationId)); + + return 1; + } + + _replayToken = _replayTokenRequest.RelevantId; + if (_replayChannelUri.IsIpc) + { + SetState(State.SEND_REPLAY_REQUEST); + } + else + { + SetState(State.AWAIT_REPLAY_CHANNEL_ENDPOINT); + } + return 1; + } + + private int Replay(int fragmentLimit, bool isControlled) + { + Image replayImage = _replayImage; + + if (null == replayImage) + { + replayImage = _replaySubscription.ImageBySessionId((int)_replaySessionId); + + if (null == replayImage) + { + if (_nanoClock.NanoTime() - _replayImageDeadline >= 0) + { + CleanUpReplay(); + CleanUpReplaySubscription(); + SetUpReplay(); + + return 1; + } + + return 0; + } + + _replayImage = replayImage; + } + + if (replayImage.Closed) + { + _position = replayImage.Position; + CleanUpLiveSubscription(); + CleanUpReplay(); + CleanUpReplaySubscription(); + RefreshRecordingDescriptor(); + + return 1; + } + + if (null == _liveSubscription && AeronClient.NULL_VALUE != _liveSubscriptionId) + { + try + { + _liveSubscription = _aeron.GetSubscription(_liveSubscriptionId); + + if (null != _liveSubscription) + { + _liveSubscriptionId = AeronClient.NULL_VALUE; + SetLiveImageDeadline(); + } + } + catch (RegistrationException e) + { + _liveSubscriptionId = AeronClient.NULL_VALUE; + + if (ErrorCode.RESOURCE_TEMPORARILY_UNAVAILABLE != e.ErrorCode()) + { + CleanUpReplay(); + CleanUpReplaySubscription(); + SetState(State.FAILED); + OnTerminalError(e); + } + else + { + _listener.OnError(e); + } + return 1; + } + } + + if (null != _liveSubscription) + { + if (_liveSubscription.ImageCount > 0) + { + _liveImage = _liveSubscription.ImageAtIndex(0); + + long livePosition = _liveImage.Position; + long replayPosition = replayImage.Position; + UpdateJoinDifference(livePosition - replayPosition); + + SetState(State.ATTEMPT_SWITCH); + + return 1; + } + else if (!_liveImageDeadlineBreached && _nanoClock.NanoTime() - _liveImageDeadline >= 0) + { + OnLiveImageDeadlineBreached(); + } + } + + int fragments = DoPoll(replayImage, fragmentLimit, isControlled); + + _position = replayImage.Position; + + if (AeronClient.NULL_VALUE == _liveSubscriptionId && + null == _liveSubscription && + _maxRecordedPosition.IsCaughtUp(_position)) + { + DoAddLiveSubscription(); + } + + return fragments; + } + + private void DoAddLiveSubscription() + { + _liveImage = null; + _liveSubscription = null; + _liveSubscriptionId = _aeron.AsyncAddSubscription(_liveChannel, _liveStreamId); + } + + private void SetLiveImageDeadline() + { + _liveImageDeadline = _nanoClock.NanoTime() + _messageTimeoutNs; + _liveImageDeadlineBreached = false; + } + + private void OnLiveImageDeadlineBreached() + { + _liveImageDeadlineBreached = true; + _listener.OnError(new AeronEvent( + "No image became available on the live subscription within " + + SystemUtil.FormatDuration(_messageTimeoutNs) + ". This could be " + + "caused by the publisher being down, or by a misconfiguration of the " + + "subscriber or a firewall between them.")); + } + + private int AttemptSwitch(int fragmentLimit, bool isControlled) + { + int fragments = 0; + + long livePosition = _liveImage.Position; + long replayPosition = _replayImage.Position; + + if (replayPosition == livePosition) + { + SetState(State.LIVE); + } + else + { + if (_replayImage.Closed) + { + _position = replayPosition; + AdvanceLastObservedLivePosition(livePosition); + CleanUpLiveSubscription(); + CleanUpReplay(); + CleanUpReplaySubscription(); + RefreshRecordingDescriptor(); + + return 1; + } + + if (_liveImage.Closed) + { + CleanUpLiveSubscription(); + ResetReplayCatchupState(); + SetState(State.REPLAY); + + return 1; + } + + fragments += _liveImage.ControlledPoll(_liveCatchupFragmentHandler, fragmentLimit); + + if (isControlled) + { + fragments += _replayImage.ControlledPoll(_replayCatchupControlledFragmentHandler, fragmentLimit); + } + else + { + fragments += _replayImage.ControlledPoll(_replayCatchupUncontrolledFragmentHandler, fragmentLimit); + } + } + + if (IsLive) + { + CleanUpReplay(); + CleanUpReplaySubscription(); + OnLiveJoined(); + } + + return fragments; + } + + private void OnTerminalError(Exception error) + { + _failureReason = error; + _listener.OnError(error); + } + + private ControlledFragmentHandlerAction OnLiveCatchupFragment( + IDirectBuffer buffer, + int offset, + int length, + Header header) + { + long currentLivePosition = header.Position; + long lastReplayPosition = _replayImage.Position; + if (currentLivePosition <= lastReplayPosition) + { + return ControlledFragmentHandlerAction.CONTINUE; + } + _nextLivePosition = currentLivePosition; + return ControlledFragmentHandlerAction.ABORT; + } + + private ControlledFragmentHandlerAction OnReplayCatchupFragmentControlled( + IDirectBuffer buffer, + int offset, + int length, + Header header) + { + long currentReplayPosition = header.Position; + if (currentReplayPosition == _nextLivePosition) + { + SetState(State.LIVE); + return ControlledFragmentHandlerAction.ABORT; + } + return _controlledFragmentAssembler.OnFragment(buffer, offset, length, header); + } + + private ControlledFragmentHandlerAction OnReplayCatchupFragmentUncontrolled( + IDirectBuffer buffer, + int offset, + int length, + Header header) + { + long currentReplayPosition = header.Position; + if (currentReplayPosition == _nextLivePosition) + { + SetState(State.LIVE); + return ControlledFragmentHandlerAction.ABORT; + } + _uncontrolledFragmentAssembler.OnFragment(buffer, offset, length, header); + return ControlledFragmentHandlerAction.CONTINUE; + } + + private int AddLiveSubscription() + { + DoAddLiveSubscription(); + + SetState(State.AWAIT_LIVE); + + return 1; + } + + private int AwaitLive() + { + if (null == _liveSubscription) + { + try + { + _liveSubscription = _aeron.GetSubscription(_liveSubscriptionId); + + if (null != _liveSubscription) + { + _liveSubscriptionId = AeronClient.NULL_VALUE; + SetLiveImageDeadline(); + } + } + catch (RegistrationException e) + { + _liveSubscriptionId = AeronClient.NULL_VALUE; + + if (ErrorCode.RESOURCE_TEMPORARILY_UNAVAILABLE == e.ErrorCode()) + { + SetState(State.ADD_LIVE_SUBSCRIPTION); + _listener.OnError(e); + } + else + { + SetState(State.FAILED); + OnTerminalError(e); + } + + return 1; + } + } + + if (null != _liveSubscription) + { + if (0 < _liveSubscription.ImageCount) + { + Image image = _liveSubscription.ImageAtIndex(0); + long livePosition = image.Position; + AdvanceLastObservedLivePosition(livePosition); + + if (_position >= 0) + { + if (livePosition < _position) + { + CleanUpLiveSubscription(); + SetState(State.FAILED); + OnTerminalError(new PersistentSubscriptionException( + PersistentSubscriptionException.Reason.GENERIC, + "live stream joined at position " + livePosition + + " which is earlier than last seen position " + _position)); + + return 1; + } + else if (livePosition > _position) + { + CleanUpLiveSubscription(); + RefreshRecordingDescriptor(); + + return 1; + } + } + + _liveImage = image; + _position = livePosition; + UpdateJoinDifference(0); + SetState(State.LIVE); + OnLiveJoined(); + + return 1; + } + else if (!_liveImageDeadlineBreached && _nanoClock.NanoTime() - _liveImageDeadline >= 0) + { + OnLiveImageDeadlineBreached(); + } + } + + return 0; + } + + private int Live(int fragmentLimit, bool isControlled) + { + Image image = _liveImage; + int fragments = DoPoll(image, fragmentLimit, isControlled); + if (0 == fragments && image.Closed) + { + long finalPosition = image.Position; + AdvanceLastObservedLivePosition(finalPosition); + _position = finalPosition; + CleanUpLiveSubscription(); + RefreshRecordingDescriptor(); + OnLiveLeft(); + + return 1; + } + return fragments; + } + + private void AdvanceLastObservedLivePosition(long livePosition) + { + if (livePosition > _lastObservedLivePosition) + { + _lastObservedLivePosition = livePosition; + } + } + + private void SetState(State newState) + { + if (newState != _state) + { + _state = newState; + if (!_stateCounter.IsClosed) + { + _stateCounter.SetRelease((int)_state); + } + if (State.FAILED == newState) + { + _asyncAeronArchive.Dispose(); + } + } + } + + private void OnLiveJoined() + { + if (!_liveJoinedCounter.IsClosed) + { + _liveJoinedCounter.IncrementRelease(); + } + + _listener.OnLiveJoined(); + } + + private void OnLiveLeft() + { + if (!_liveLeftCounter.IsClosed) + { + _liveLeftCounter.IncrementRelease(); + } + + _listener.OnLiveLeft(); + } + + private int DoPoll(Image image, int fragmentLimit, bool isControlled) + { + if (isControlled) + { + return image.ControlledPoll(_controlledFragmentAssembler, fragmentLimit); + } + return image.Poll(_uncontrolledFragmentAssembler, fragmentLimit); + } + + private ControlledFragmentHandlerAction OnFragmentControlled( + IDirectBuffer buffer, + int offset, + int length, + Header header) + { + return _controlledFragmentHandler.OnFragment(buffer, offset, length, header); + } + + private void OnFragmentUncontrolled( + IDirectBuffer buffer, + int offset, + int length, + Header header) + { + _uncontrolledFragmentHandler.OnFragment(buffer, offset, length, header); + } + + private enum ReplayChannelType + { + SESSION_SPECIFIC, + DYNAMIC_PORT, + RESPONSE_CHANNEL + } + + private static class ReplayChannelTypeHelper + { + internal static ReplayChannelType Of(ChannelUri channelUri) + { + if (channelUri.HasControlModeResponse()) + { + return ReplayChannelType.RESPONSE_CHANNEL; + } + if (channelUri.IsUdp) + { + string endpoint = channelUri.Get(AeronClient.Context.ENDPOINT_PARAM_NAME); + if (null != endpoint && endpoint.EndsWith(":0", StringComparison.Ordinal)) + { + return ReplayChannelType.DYNAMIC_PORT; + } + } + return ReplayChannelType.SESSION_SPECIFIC; + } + } + + private enum State + { + AWAIT_ARCHIVE_CONNECTION = 0, + SEND_LIST_RECORDING_REQUEST = 1, + AWAIT_LIST_RECORDING_RESPONSE = 2, + SEND_REPLAY_REQUEST = 3, + AWAIT_REPLAY_RESPONSE = 4, + ADD_REPLAY_SUBSCRIPTION = 5, + AWAIT_REPLAY_SUBSCRIPTION = 6, + AWAIT_REPLAY_CHANNEL_ENDPOINT = 7, + ADD_REQUEST_PUBLICATION = 8, + AWAIT_REQUEST_PUBLICATION = 9, + SEND_REPLAY_TOKEN_REQUEST = 10, + AWAIT_REPLAY_TOKEN = 11, + REPLAY = 12, + ATTEMPT_SWITCH = 13, + ADD_LIVE_SUBSCRIPTION = 14, + AWAIT_LIVE = 15, + LIVE = 16, + FAILED = 17 + } + + private class AsyncArchiveOp + { + internal long CorrelationId { get; set; } + internal long DeadlineNs { get; set; } + + internal long RelevantId { get; set; } + internal ControlResponseCode Code { get; set; } + internal string ErrorMessage { get; set; } + + internal bool ResponseReceived { get; set; } + + internal void Init(long correlationId, long deadlineNs) + { + CorrelationId = correlationId; + DeadlineNs = deadlineNs; + ResponseReceived = false; + } + + internal void OnControlResponse(long relevantId, ControlResponseCode code, string errorMessage) + { + RelevantId = relevantId; + Code = code; + ErrorMessage = errorMessage; + ResponseReceived = true; + } + } + + private sealed class ListRecordingRequest : AsyncArchiveOp, IRecordingDescriptorConsumer + { + internal int Remaining { get; set; } + + internal long RecordingId { get; set; } + internal long StartPosition { get; set; } + internal long StopPosition { get; set; } + internal int TermBufferLength { get; set; } + internal int StreamId { get; set; } + + public void OnRecordingDescriptor( + long controlSessionId, + long correlationId, + long recordingId, + long startTimestamp, + long stopTimestamp, + long startPosition, + long stopPosition, + int initialTermId, + int segmentFileLength, + int termBufferLength, + int mtuLength, + int sessionId, + int streamId, + string strippedChannel, + string originalChannel, + string sourceIdentity) + { + RecordingId = recordingId; + StartPosition = startPosition; + StopPosition = stopPosition; + TermBufferLength = termBufferLength; + StreamId = streamId; + + if (0 == --Remaining) + { + ResponseReceived = true; + } + } + } + + /// + /// Configuration of a PersistentSubscription to be created. + /// + public sealed class Context + { + private int _isConcluded; + private AeronClient _aeron; + private bool _ownsAeronClient; + private string _aeronDirectoryName; + private long _recordingId = AeronClient.NULL_VALUE; + private long _startPosition = FROM_LIVE; + private string _liveChannel; + private int _liveStreamId = AeronClient.NULL_VALUE; + private string _replayChannel; + private int _replayStreamId = AeronClient.NULL_VALUE; + private IPersistentSubscriptionListener _listener; + private AeronArchive.Context _aeronArchiveContext; + private Counter _stateCounter; + private Counter _joinDifferenceCounter; + private Counter _liveLeftCounter; + private Counter _liveJoinedCounter; + + /// + /// Construct a Context using default values. + /// + public Context() + { + } + + /// + /// Perform a shallow copy of the object. + /// + /// a shallow copy of the object. + public Context Clone() + { + return (Context)MemberwiseClone(); + } + + /// + /// Conclude configuration by setting up defaults when specifics are not provided. + /// + public void Conclude() + { + if (0 != Interlocked.Exchange(ref _isConcluded, 1)) + { + throw new ConcurrentConcludeException(); + } + + if (AeronClient.NULL_VALUE == _recordingId) + { + throw new ConfigurationException("recordingId must be set"); + } + + if (AeronClient.NULL_VALUE == _liveStreamId) + { + throw new ConfigurationException("liveStreamId must be set"); + } + + if (string.IsNullOrEmpty(_liveChannel)) + { + throw new ConfigurationException("liveChannel must be set"); + } + + if (string.IsNullOrEmpty(_replayChannel)) + { + throw new ConfigurationException("replayChannel must be set"); + } + + if (AeronClient.NULL_VALUE == _replayStreamId) + { + throw new ConfigurationException("replayStreamId must be set"); + } + + if (null == _aeronArchiveContext) + { + throw new ConfigurationException("aeronArchiveContext must be set"); + } + + if (null == _listener) + { + _listener = new NoOpPersistentSubscriptionListener(); + } + + if (0 > _recordingId) + { + throw new ConfigurationException("invalid recordingId " + _recordingId); + } + + if (FROM_LIVE > _startPosition) + { + throw new ConfigurationException("invalid startPosition " + _startPosition); + } + + ChannelUri replayChannelUri = ChannelUri.Parse(_replayChannel); + + if (replayChannelUri.HasControlModeResponse()) + { + string controlRequestChannel = _aeronArchiveContext.ControlRequestChannel(); + if (null != controlRequestChannel && + !replayChannelUri.IsIpc == ChannelUri.Parse(controlRequestChannel).IsIpc) + { + throw new ConfigurationException( + "Channel media type mismatch. " + + "When using `control-mode=response`, the `replayChannel` media type must match" + + " the media type for the archive control channel."); + } + } + + replayChannelUri.Put(AeronClient.Context.REJOIN_PARAM_NAME, "false"); + + _replayChannel = replayChannelUri.ToString(); + + if (null == _aeron) + { + AeronClient.Context aeronCtx = new AeronClient.Context() + .ClientName("PersistentSubscription") + .SubscriberErrorHandler(RethrowingErrorHandler.INSTANCE) + .UseConductorAgentInvoker(true); + if (null != _aeronDirectoryName) + { + aeronCtx.AeronDirectoryName(_aeronDirectoryName); + } + _aeron = AeronClient.Connect(aeronCtx); + _ownsAeronClient = true; + } + + if (null == _aeronArchiveContext.AeronClient()) + { + _aeronArchiveContext.AeronClient(_aeron); + } + + AllocateMissingCounters(); + } + + private void AllocateMissingCounters() + { + if (null == _stateCounter) + { + _stateCounter = AllocatePersistentSubscriptionCounter( + _aeron, + "Persistent Subscription State", + AeronCounters.PERSISTENT_SUBSCRIPTION_STATE_TYPE_ID, + _replayStreamId, + _liveStreamId, + _replayChannel, + _liveChannel); + } + + if (null == _joinDifferenceCounter) + { + _joinDifferenceCounter = AllocatePersistentSubscriptionCounter( + _aeron, + "Persistent Subscription Join Difference", + AeronCounters.PERSISTENT_SUBSCRIPTION_JOIN_DIFFERENCE_TYPE_ID, + _replayStreamId, + _liveStreamId, + _replayChannel, + _liveChannel); + } + + if (null == _liveLeftCounter) + { + _liveLeftCounter = AllocatePersistentSubscriptionCounter( + _aeron, + "Persistent Subscription Live Left Count", + AeronCounters.PERSISTENT_SUBSCRIPTION_LIVE_LEFT_COUNT_TYPE_ID, + _replayStreamId, + _liveStreamId, + _replayChannel, + _liveChannel); + } + + if (null == _liveJoinedCounter) + { + _liveJoinedCounter = AllocatePersistentSubscriptionCounter( + _aeron, + "Persistent Subscription Live Joined Count", + AeronCounters.PERSISTENT_SUBSCRIPTION_LIVE_JOINED_COUNT_TYPE_ID, + _replayStreamId, + _liveStreamId, + _replayChannel, + _liveChannel); + } + } + + /// + /// True if the method has been called. + /// + public bool IsConcluded => 0 != Volatile.Read(ref _isConcluded); + + public Context Aeron(AeronClient aeron) + { + _aeron = aeron; + return this; + } + + public AeronClient Aeron() + { + return _aeron; + } + + public Context OwnsAeronClient(bool ownsAeronClient) + { + _ownsAeronClient = ownsAeronClient; + return this; + } + + public bool OwnsAeronClient() + { + return _ownsAeronClient; + } + + public Context AeronDirectoryName(string aeronDirectoryName) + { + _aeronDirectoryName = aeronDirectoryName; + return this; + } + + public string AeronDirectoryName() + { + return _aeronDirectoryName; + } + + public Context RecordingId(long recordingId) + { + _recordingId = recordingId; + return this; + } + + public long RecordingId() + { + return _recordingId; + } + + public Context StartPosition(long startPosition) + { + _startPosition = startPosition; + return this; + } + + public long StartPosition() + { + return _startPosition; + } + + public Context LiveChannel(string liveChannel) + { + _liveChannel = liveChannel; + return this; + } + + public string LiveChannel() + { + return _liveChannel; + } + + public Context LiveStreamId(int liveStreamId) + { + _liveStreamId = liveStreamId; + return this; + } + + public int LiveStreamId() + { + return _liveStreamId; + } + + public Context ReplayChannel(string replayChannel) + { + _replayChannel = replayChannel; + return this; + } + + public string ReplayChannel() + { + return _replayChannel; + } + + public Context ReplayStreamId(int replayStreamId) + { + _replayStreamId = replayStreamId; + return this; + } + + public int ReplayStreamId() + { + return _replayStreamId; + } + + public Context Listener(IPersistentSubscriptionListener listener) + { + _listener = listener; + return this; + } + + public IPersistentSubscriptionListener Listener() + { + return _listener; + } + + public Context AeronArchiveContext(AeronArchive.Context aeronArchiveContext) + { + _aeronArchiveContext = aeronArchiveContext; + return this; + } + + public AeronArchive.Context AeronArchiveContext() + { + return _aeronArchiveContext; + } + + public Context StateCounter(Counter stateCounter) + { + _stateCounter = stateCounter; + return this; + } + + public Counter StateCounter() + { + return _stateCounter; + } + + public Context JoinDifferenceCounter(Counter joinDifferenceCounter) + { + _joinDifferenceCounter = joinDifferenceCounter; + return this; + } + + public Counter JoinDifferenceCounter() + { + return _joinDifferenceCounter; + } + + public Context LiveLeftCounter(Counter liveLeftCounter) + { + _liveLeftCounter = liveLeftCounter; + return this; + } + + public Counter LiveLeftCounter() + { + return _liveLeftCounter; + } + + public Context LiveJoinedCounter(Counter liveJoinedCounter) + { + _liveJoinedCounter = liveJoinedCounter; + return this; + } + + public Counter LiveJoinedCounter() + { + return _liveJoinedCounter; + } + + /// + /// Close the context and free applicable resources. If is true the + /// client will be closed. + /// + public void Dispose() + { + if (_ownsAeronClient) + { + CloseHelper.QuietDispose(_aeron); + } + else if (null != _aeron && !_aeron.IsClosed) + { + CloseHelper.QuietDispose(_stateCounter); + CloseHelper.QuietDispose(_joinDifferenceCounter); + CloseHelper.QuietDispose(_liveLeftCounter); + CloseHelper.QuietDispose(_liveJoinedCounter); + } + } + } + + private sealed class NoOpPersistentSubscriptionListener : IPersistentSubscriptionListener + { + public void OnLiveJoined() + { + } + + public void OnLiveLeft() + { + } + + public void OnError(Exception e) + { + } + } + + private sealed class MaxRecordedPosition : AsyncArchiveOp + { + private enum MaxRecordedPositionState + { + REQUEST_MAX_POSITION, + AWAIT_MAX_POSITION, + RECHECK_REQUIRED + } + + private readonly PersistentSubscription _parent; + private MaxRecordedPositionState _state = MaxRecordedPositionState.REQUEST_MAX_POSITION; + private long _maxRecordedPosition; + private int _closeEnoughThreshold; + + internal MaxRecordedPosition(PersistentSubscription parent) + { + _parent = parent; + } + + internal void Reset(int closeEnoughThreshold) + { + _closeEnoughThreshold = closeEnoughThreshold; + _state = MaxRecordedPositionState.REQUEST_MAX_POSITION; + } + + internal bool IsCaughtUp(long replayedPosition) + { + switch (_state) + { + case MaxRecordedPositionState.REQUEST_MAX_POSITION: + return RequestMaxPosition(); + case MaxRecordedPositionState.AWAIT_MAX_POSITION: + return AwaitMaxPosition(replayedPosition); + case MaxRecordedPositionState.RECHECK_REQUIRED: + return RecheckRequired(replayedPosition); + default: + return false; + } + } + + private bool RequestMaxPosition() + { + long correlationId = _parent._aeron.NextCorrelationId(); + if (_parent._asyncAeronArchive.TrySendMaxRecordedPositionRequest(correlationId, _parent._recordingId)) + { + Init(correlationId, _parent._nanoClock.NanoTime() + _parent._messageTimeoutNs); + _state = MaxRecordedPositionState.AWAIT_MAX_POSITION; + } + return false; + } + + private bool AwaitMaxPosition(long replayedPosition) + { + if (ResponseReceived) + { + if (ControlResponseCode.OK == Code) + { + _maxRecordedPosition = RelevantId; + if (CloseEnoughToSwitch(replayedPosition, _maxRecordedPosition)) + { + return true; + } + else + { + _state = MaxRecordedPositionState.RECHECK_REQUIRED; + return false; + } + } + else + { + ArchiveException archiveException = new ArchiveException( + "get max position request failed code=" + Code + " relevantId=" + RelevantId + + " errorMessage='" + ErrorMessage + "'"); + _parent.SetState(State.FAILED); + _parent.OnTerminalError(archiveException); + } + } + else + { + if (DeadlineNs - _parent._nanoClock.NanoTime() < 0) + { + _state = MaxRecordedPositionState.REQUEST_MAX_POSITION; + } + } + return false; + } + + private bool RecheckRequired(long replayedPosition) + { + if (CloseEnoughToReCheck(replayedPosition)) + { + _state = MaxRecordedPositionState.REQUEST_MAX_POSITION; + } + return false; + } + + private bool CloseEnoughToSwitch(long replayedPosition, long maxRecordedPosition) + { + return replayedPosition >= maxRecordedPosition - _closeEnoughThreshold; + } + + private bool CloseEnoughToReCheck(long replayedPosition) + { + return replayedPosition >= _maxRecordedPosition; + } + } + + private sealed class ArchiveListener : IAsyncAeronArchiveListener + { + private readonly PersistentSubscription _parent; + + internal ArchiveListener(PersistentSubscription parent) + { + _parent = parent; + } + + public void OnConnected() + { + } + + public void OnDisconnected() + { + if (State.AWAIT_ARCHIVE_CONNECTION == _parent._state || + State.ATTEMPT_SWITCH == _parent._state || + State.LIVE == _parent._state || + State.FAILED == _parent._state) + { + return; + } + + Image replayImage = _parent._replayImage; + if (null != replayImage) + { + _parent._position = replayImage.Position; + } + + _parent.CleanUpRequestPublication(); + _parent.CleanUpLiveSubscription(); + _parent.CleanUpReplay(); + _parent.CleanUpReplaySubscription(); + + _parent.SetState(State.AWAIT_ARCHIVE_CONNECTION); + } + + public void OnControlResponse( + long correlationId, + long relevantId, + ControlResponseCode code, + string errorMessage) + { + if (correlationId == _parent._maxRecordedPosition.CorrelationId) + { + _parent._maxRecordedPosition.OnControlResponse(relevantId, code, errorMessage); + } + else if (correlationId == _parent._listRecordingRequest.CorrelationId) + { + _parent._listRecordingRequest.OnControlResponse(relevantId, code, errorMessage); + } + else if (correlationId == _parent._replayRequest.CorrelationId) + { + _parent._replayRequest.OnControlResponse(relevantId, code, errorMessage); + } + else if (correlationId == _parent._replayTokenRequest.CorrelationId) + { + _parent._replayTokenRequest.OnControlResponse(relevantId, code, errorMessage); + } + } + + public void OnError(Exception error) + { + if (_parent._asyncAeronArchive.IsClosed) + { + _parent.SetState(State.FAILED); + _parent.OnTerminalError(error); + } + else + { + _parent._listener.OnError(error); + } + } + + public void OnRecordingDescriptor( + long controlSessionId, + long correlationId, + long recordingId, + long startTimestamp, + long stopTimestamp, + long startPosition, + long stopPosition, + int initialTermId, + int segmentFileLength, + int termBufferLength, + int mtuLength, + int sessionId, + int streamId, + string strippedChannel, + string originalChannel, + string sourceIdentity) + { + if (correlationId == _parent._listRecordingRequest.CorrelationId) + { + _parent._listRecordingRequest.OnRecordingDescriptor( + controlSessionId, + correlationId, + recordingId, + startTimestamp, + stopTimestamp, + startPosition, + stopPosition, + initialTermId, + segmentFileLength, + termBufferLength, + mtuLength, + sessionId, + streamId, + strippedChannel, + originalChannel, + sourceIdentity); + } + } + } + + private sealed class InlineControlledFragmentHandler : IControlledFragmentHandler + { + private readonly Func _handler; + + internal InlineControlledFragmentHandler( + Func handler) + { + _handler = handler; + } + + public ControlledFragmentHandlerAction OnFragment( + IDirectBuffer buffer, int offset, int length, Header header) + { + return _handler(buffer, offset, length, header); + } + } + + private sealed class InlineFragmentHandler : IFragmentHandler + { + private readonly Action _handler; + + internal InlineFragmentHandler(Action handler) + { + _handler = handler; + } + + public void OnFragment(IDirectBuffer buffer, int offset, int length, Header header) + { + _handler(buffer, offset, length, header); + } + } + + private static Counter AllocatePersistentSubscriptionCounter( + AeronClient aeron, + string name, + int typeId, + int replayStreamId, + int liveStreamId, + string replayChannel, + string liveChannel) + { + string label = + name + ": " + replayStreamId + " " + replayChannel + " " + liveStreamId + " " + liveChannel; + + return aeron.AddCounter(typeId, label); + } + } +} diff --git a/src/Adaptive.Archiver/PersistentSubscriptionException.cs b/src/Adaptive.Archiver/PersistentSubscriptionException.cs new file mode 100644 index 00000000..7bcda7f3 --- /dev/null +++ b/src/Adaptive.Archiver/PersistentSubscriptionException.cs @@ -0,0 +1,61 @@ +/* + * Copyright 2014 - 2026 Adaptive Financial Consulting Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Adaptive.Aeron.Exceptions; + +namespace Adaptive.Archiver +{ + /// + /// Exception raised when using a . + /// + /// Since 1.51.0 + public sealed class PersistentSubscriptionException : AeronException + { + /// + /// Reason a occurred. + /// + public enum Reason + { + /// A generic reason in case no specific reason is available. + GENERIC, + + /// No recording exists with the specified recording id. + RECORDING_NOT_FOUND, + + /// The requested live stream id does not match the stream id for the recording. + STREAM_ID_MISMATCH, + + /// The requested start position is not available for the specified recording. + INVALID_START_POSITION + } + + /// + /// Persistent Subscription exception with a detailed message and provided reason. + /// + /// for the error. + /// providing detail on the error. + public PersistentSubscriptionException(Reason reason, string message) + : base(message) + { + ReasonValue = reason; + } + + /// + /// The reason indicating the type of error that caused the exception. + /// + public Reason ReasonValue { get; } + } +} diff --git a/src/Adaptive.Archiver/PublicAPI.Unshipped.txt b/src/Adaptive.Archiver/PublicAPI.Unshipped.txt index e69de29b..cccdec47 100644 --- a/src/Adaptive.Archiver/PublicAPI.Unshipped.txt +++ b/src/Adaptive.Archiver/PublicAPI.Unshipped.txt @@ -0,0 +1,66 @@ +Adaptive.Archiver.ArchiveEvent +Adaptive.Archiver.ArchiveEvent.ArchiveEvent(string message) -> void +Adaptive.Archiver.ArchiveEvent.ArchiveEvent(string message, Adaptive.Aeron.Exceptions.Category category) -> void +Adaptive.Archiver.IPersistentSubscriptionListener +Adaptive.Archiver.IPersistentSubscriptionListener.OnError(System.Exception e) -> void +Adaptive.Archiver.IPersistentSubscriptionListener.OnLiveJoined() -> void +Adaptive.Archiver.IPersistentSubscriptionListener.OnLiveLeft() -> void +Adaptive.Archiver.PersistentSubscription +Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.Aeron() -> Adaptive.Aeron.Aeron +Adaptive.Archiver.PersistentSubscription.Context.Aeron(Adaptive.Aeron.Aeron aeron) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.AeronArchiveContext() -> Adaptive.Archiver.AeronArchive.Context +Adaptive.Archiver.PersistentSubscription.Context.AeronArchiveContext(Adaptive.Archiver.AeronArchive.Context aeronArchiveContext) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.AeronDirectoryName() -> string +Adaptive.Archiver.PersistentSubscription.Context.AeronDirectoryName(string aeronDirectoryName) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.Clone() -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.Conclude() -> void +Adaptive.Archiver.PersistentSubscription.Context.Context() -> void +Adaptive.Archiver.PersistentSubscription.Context.Dispose() -> void +Adaptive.Archiver.PersistentSubscription.Context.IsConcluded.get -> bool +Adaptive.Archiver.PersistentSubscription.Context.JoinDifferenceCounter() -> Adaptive.Aeron.Counter +Adaptive.Archiver.PersistentSubscription.Context.JoinDifferenceCounter(Adaptive.Aeron.Counter joinDifferenceCounter) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.Listener() -> Adaptive.Archiver.IPersistentSubscriptionListener +Adaptive.Archiver.PersistentSubscription.Context.Listener(Adaptive.Archiver.IPersistentSubscriptionListener listener) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.LiveChannel() -> string +Adaptive.Archiver.PersistentSubscription.Context.LiveChannel(string liveChannel) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.LiveJoinedCounter() -> Adaptive.Aeron.Counter +Adaptive.Archiver.PersistentSubscription.Context.LiveJoinedCounter(Adaptive.Aeron.Counter liveJoinedCounter) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.LiveLeftCounter() -> Adaptive.Aeron.Counter +Adaptive.Archiver.PersistentSubscription.Context.LiveLeftCounter(Adaptive.Aeron.Counter liveLeftCounter) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.LiveStreamId() -> int +Adaptive.Archiver.PersistentSubscription.Context.LiveStreamId(int liveStreamId) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.OwnsAeronClient() -> bool +Adaptive.Archiver.PersistentSubscription.Context.OwnsAeronClient(bool ownsAeronClient) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.RecordingId() -> long +Adaptive.Archiver.PersistentSubscription.Context.RecordingId(long recordingId) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.ReplayChannel() -> string +Adaptive.Archiver.PersistentSubscription.Context.ReplayChannel(string replayChannel) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.ReplayStreamId() -> int +Adaptive.Archiver.PersistentSubscription.Context.ReplayStreamId(int replayStreamId) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.StartPosition() -> long +Adaptive.Archiver.PersistentSubscription.Context.StartPosition(long startPosition) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.Context.StateCounter() -> Adaptive.Aeron.Counter +Adaptive.Archiver.PersistentSubscription.Context.StateCounter(Adaptive.Aeron.Counter stateCounter) -> Adaptive.Archiver.PersistentSubscription.Context +Adaptive.Archiver.PersistentSubscription.ControlledPoll(Adaptive.Aeron.LogBuffer.IControlledFragmentHandler fragmentHandler, int fragmentLimit) -> int +Adaptive.Archiver.PersistentSubscription.Dispose() -> void +Adaptive.Archiver.PersistentSubscription.FailureReason.get -> System.Exception +Adaptive.Archiver.PersistentSubscription.HasFailed.get -> bool +Adaptive.Archiver.PersistentSubscription.IsLive.get -> bool +Adaptive.Archiver.PersistentSubscription.IsReplaying.get -> bool +Adaptive.Archiver.PersistentSubscription.Poll(Adaptive.Aeron.LogBuffer.IFragmentHandler fragmentHandler, int fragmentLimit) -> int +Adaptive.Archiver.PersistentSubscriptionException +Adaptive.Archiver.PersistentSubscriptionException.PersistentSubscriptionException(Adaptive.Archiver.PersistentSubscriptionException.Reason reason, string message) -> void +Adaptive.Archiver.PersistentSubscriptionException.Reason +Adaptive.Archiver.PersistentSubscriptionException.Reason.GENERIC = 0 -> Adaptive.Archiver.PersistentSubscriptionException.Reason +Adaptive.Archiver.PersistentSubscriptionException.Reason.INVALID_START_POSITION = 3 -> Adaptive.Archiver.PersistentSubscriptionException.Reason +Adaptive.Archiver.PersistentSubscriptionException.Reason.RECORDING_NOT_FOUND = 1 -> Adaptive.Archiver.PersistentSubscriptionException.Reason +Adaptive.Archiver.PersistentSubscriptionException.Reason.STREAM_ID_MISMATCH = 2 -> Adaptive.Archiver.PersistentSubscriptionException.Reason +Adaptive.Archiver.PersistentSubscriptionException.ReasonValue.get -> Adaptive.Archiver.PersistentSubscriptionException.Reason +const Adaptive.Archiver.ArchiveException.INVALID_POSITION = 16 -> int +const Adaptive.Archiver.PersistentSubscription.FROM_LIVE = -2 -> long +const Adaptive.Archiver.PersistentSubscription.FROM_START = -1 -> long +static Adaptive.Archiver.ArchiveException.BuildReplayBeforeStartErrorMsg(long recordingId, long replayStartPosition, long startPosition) -> string +static Adaptive.Archiver.ArchiveException.BuildReplayExceedsLimitErrorMsg(long recordingId, long replayStartPosition, long limitPosition) -> string +static Adaptive.Archiver.ArchiveException.BuildUnknownRecordingErrorMsg(long recordingId) -> string +static Adaptive.Archiver.PersistentSubscription.Create(Adaptive.Archiver.PersistentSubscription.Context ctx) -> Adaptive.Archiver.PersistentSubscription diff --git a/src/Adaptive.Cluster.Tests/Client/AeronClusterContextTest.cs b/src/Adaptive.Cluster.Tests/Client/AeronClusterContextTest.cs index fd9403a2..ad36c989 100644 --- a/src/Adaptive.Cluster.Tests/Client/AeronClusterContextTest.cs +++ b/src/Adaptive.Cluster.Tests/Client/AeronClusterContextTest.cs @@ -46,7 +46,7 @@ public void ConcludeThrowsConfigurationExceptionIfIngressChannelIsNotSet(string _context.IngressChannel(ingressChannel); var exception = Assert.Throws(() => _context.Conclude()); - Assert.AreEqual("ingressChannel must be specified", exception.Message); + Assert.AreEqual("ERROR - ingressChannel must be specified", exception.Message); } [Test] @@ -56,7 +56,7 @@ public void ConcludeThrowsConfigurationExceptionIfIngressChannelIsSetToIpcAndIng var exception = Assert.Throws(() => _context.Conclude()); Assert.AreEqual( - "AeronCluster.Context ingressEndpoints must be null when using IPC ingress", + "ERROR - AeronCluster.Context ingressEndpoints must be null when using IPC ingress", exception.Message ); } @@ -68,7 +68,7 @@ public void ConcludeThrowsConfigurationExceptionIfEgressChannelIsNotSet(string e _context.EgressChannel(egressChannel); var exception = Assert.Throws(() => _context.Conclude()); - Assert.AreEqual("egressChannel must be specified", exception.Message); + Assert.AreEqual("ERROR - egressChannel must be specified", exception.Message); } [TestCase(null)] @@ -109,7 +109,8 @@ public void ClientNameMustNotExceedMaxLength() var exception = Assert.Throws(() => _context.Conclude()); Assert.AreEqual( - "AeronCluster.Context.clientName length must be <= " + AeronType.Configuration.MAX_CLIENT_NAME_LENGTH, + "ERROR - AeronCluster.Context.clientName length must be <= " + + AeronType.Configuration.MAX_CLIENT_NAME_LENGTH, exception.Message ); } diff --git a/src/Adaptive.Cluster.Tests/Client/EgressAdapterTest.cs b/src/Adaptive.Cluster.Tests/Client/EgressAdapterTest.cs index a3f9605b..cb262e81 100644 --- a/src/Adaptive.Cluster.Tests/Client/EgressAdapterTest.cs +++ b/src/Adaptive.Cluster.Tests/Client/EgressAdapterTest.cs @@ -78,7 +78,9 @@ public void DefaultEgressListenerBehaviourShouldThrowClusterExceptionOnUnknownSc var listener = A.Fake(); var adapter = new EgressAdapter(listener, 42, A.Fake(), 5); var exception = Assert.Throws(() => adapter.OnFragment(_buffer, 0, 64, new Header(0, 0))); - Assert.AreEqual("expected schemaId=" + MessageHeaderDecoder.SCHEMA_ID + ", actual=0", exception.Message); + Assert.AreEqual( + "ERROR - expected schemaId=" + MessageHeaderDecoder.SCHEMA_ID + ", actual=0", + exception.Message); } [Test] diff --git a/src/Adaptive.Cluster/Adaptive.Cluster.csproj b/src/Adaptive.Cluster/Adaptive.Cluster.csproj index a3f686fd..a252e810 100644 --- a/src/Adaptive.Cluster/Adaptive.Cluster.csproj +++ b/src/Adaptive.Cluster/Adaptive.Cluster.csproj @@ -3,7 +3,7 @@ netstandard2.0 true Aeron.Cluster - 1.49.0 + 1.51.0 Adaptive Financial Consulting Ltd. Adaptive Financial Consulting Ltd. Clustering libraries over the Aeron transport diff --git a/src/Adaptive.Cluster/Client/AeronCluster.cs b/src/Adaptive.Cluster/Client/AeronCluster.cs index a56f6b1c..4a744959 100644 --- a/src/Adaptive.Cluster/Client/AeronCluster.cs +++ b/src/Adaptive.Cluster/Client/AeronCluster.cs @@ -15,7 +15,7 @@ */ using System; -using System.Linq; +using System.Globalization; using System.Threading; using Adaptive.Aeron; using Adaptive.Aeron.Exceptions; @@ -476,27 +476,13 @@ public static AeronCluster Connect(Context ctx) { ctx.Conclude(); - Aeron.Aeron aeron = ctx.AeronClient(); - long deadlineNs = aeron.Ctx.NanoClock().NanoTime() + ctx.MessageTimeoutNs(); - asyncConnect = new AsyncConnect(ctx, deadlineNs); - AgentInvoker aeronClientInvoker = aeron.ConductorAgentInvoker; - AgentInvoker agentInvoker = ctx.AgentInvoker(); IIdleStrategy idleStrategy = ctx.IdleStrategy(); + asyncConnect = new AsyncConnect(ctx); AeronCluster aeronCluster; AsyncConnect.AsyncConnectState state = asyncConnect.State(); while (null == (aeronCluster = asyncConnect.Poll())) { - if (null != aeronClientInvoker) - { - aeronClientInvoker.Invoke(); - } - - if (null != agentInvoker) - { - agentInvoker.Invoke(); - } - if (state != asyncConnect.State()) { state = asyncConnect.State(); @@ -549,8 +535,7 @@ public static AsyncConnect ConnectAsync(Context ctx) { ctx.Conclude(); - long deadlineNs = ctx.AeronClient().Ctx.NanoClock().NanoTime() + ctx.MessageTimeoutNs(); - return new AsyncConnect(ctx, deadlineNs); + return new AsyncConnect(ctx); } catch (Exception) { @@ -1016,15 +1001,14 @@ string ingressEndpoints _sessionMessageEncoder.LeadershipTermId(leadershipTermId); _publication?.Dispose(); - if (_ctx.IngressEndpoints() != null) + if (null == _ctx.IngressEndpoints()) { - _fragmentAssembler.Clear(); - _ctx.IngressEndpoints(ingressEndpoints); - UpdateMemberEndpoints(ingressEndpoints, leaderMemberId); + _publication = AddNewLeaderIngressPublication(_ctx, _ctx.IngressChannel(), _ctx.IngressStreamId()); } else { - _publication = AddNewLeaderIngressPublication(_ctx, _ctx.IngressChannel(), _ctx.IngressStreamId()); + _ctx.IngressEndpoints(ingressEndpoints); + UpdateMemberEndpoints(ingressEndpoints, leaderMemberId); } _fragmentAssembler.Clear(); @@ -1073,7 +1057,8 @@ private static Map ParseIngressEndpoints(Context ctx, string throw new ConfigurationException("invalid format - member missing '=' separator: " + endpoints); } - int memberId = int.Parse(endpoint.Substring(0, separatorIndex)); + int memberId = int.Parse( + endpoint.Substring(0, separatorIndex), NumberStyles.Integer, CultureInfo.InvariantCulture); endpointByIdMap.Put( memberId, new MemberIngress(ctx, memberId, endpoint.Substring(separatorIndex + 1)) @@ -1116,7 +1101,7 @@ private Publication AddNewLeaderIngressPublication(Context ctx, string channel, ", leadershipTermId=" + _leadershipTermId + ", channel=" + channel + ", streamId=" + streamId + - ") within " + ctx.MessageTimeoutNs() + "ns" + ") within " + SystemUtil.FormatDuration(ctx.MessageTimeoutNs()) ); } @@ -1619,6 +1604,7 @@ public void Conclude() new Aeron.Aeron.Context() .AeronDirectoryName(_aeronDirectoryName) .ErrorHandler(_errorHandler) + .SubscriberErrorHandler(RethrowingErrorHandler.INSTANCE) .ClientName(string.IsNullOrEmpty(_clientName) ? "cluster-client" : _clientName) ); _ownsAeronClient = true; @@ -2249,6 +2235,11 @@ public enum AsyncConnectState private long _ingressRegistrationId = NULL_VALUE; private Publication _ingressPublication; + internal AsyncConnect(Context ctx) + : this(ctx, ctx.AeronClient().Ctx.NanoClock().NanoTime() + ctx.MessageTimeoutNs()) + { + } + internal AsyncConnect(Context ctx, long deadlineNs) { _ctx = ctx; @@ -2374,17 +2365,17 @@ private void CheckDeadline() if (_deadlineNs - _nanoClock.NanoTime() < 0) { bool isConnected = null != _egressSubscription && _egressSubscription.IsConnected; - string endpointPort = + string egressChannel = null != _egressSubscription ? _egressSubscription.TryResolveChannelEndpointPort() : ""; AeronTimeoutException ex = new AeronTimeoutException( "cluster connect timeout: state=" + _state + - " messageTimeout=" + _ctx.MessageTimeoutNs() + "ns" + + " messageTimeout=" + SystemUtil.FormatDuration(_ctx.MessageTimeoutNs()) + " ingressChannel=" + _ctx.IngressChannel() + " ingressEndpoints=" + _ctx.IngressEndpoints() + " ingressPublication=" + _ingressPublication + " egress.isConnected=" + isConnected + - " responseChannel=" + endpointPort); + " responseChannel=" + egressChannel); foreach (MemberIngress member in _memberByIdMap.Values) { @@ -2470,58 +2461,25 @@ private void CreateIngressPublications() } else { - int publicationCount = 0; - int failureCount = 0; - ChannelUri channelUri = ChannelUri.Parse(_ctx.IngressChannel()); - + int count = 0; foreach (MemberIngress member in _memberByIdMap.Values) { - try + if (null != member._publication || null != member._publicationException) { - if (null != member._publicationException) - { - failureCount++; - continue; - } - - if (null == member._publication) - { - if (NULL_VALUE == member._registrationId) - { - if (channelUri.IsUdp) - { - channelUri.Put(ENDPOINT_PARAM_NAME, member._endpoint); - } - - member._registrationId = AsyncAddIngressPublication( - _ctx, - channelUri.ToString(), - _ctx.IngressStreamId() - ); - } - - member._publication = GetIngressPublication(_ctx, member._registrationId); - } - - if (null != member._publication) - { - member._registrationId = NULL_VALUE; - publicationCount++; - } + count++; } - catch (RegistrationException ex) + else { - member._publicationException = ex; + if (NULL_VALUE == member._registrationId) + { + member.AsyncAddPublication(); + } + member.AsyncGetPublication(); } } - if (publicationCount + failureCount == _memberByIdMap.Count) + if (_memberByIdMap.Count == count) { - if (0 == publicationCount) - { - throw _memberByIdMap.Values.First()._publicationException; - } - State(AWAIT_PUBLICATION_CONNECTED); } } diff --git a/src/Adaptive.Cluster/Client/EgressPoller.cs b/src/Adaptive.Cluster/Client/EgressPoller.cs index 2ee8a023..ab0f19aa 100644 --- a/src/Adaptive.Cluster/Client/EgressPoller.cs +++ b/src/Adaptive.Cluster/Client/EgressPoller.cs @@ -14,6 +14,7 @@ * limitations under the License. */ +using System; using Adaptive.Aeron; using Adaptive.Aeron.LogBuffer; using Adaptive.Agrona; @@ -300,12 +301,10 @@ public ControlledFragmentHandlerAction OnFragment(IDirectBuffer buffer, int offs _messageHeaderDecoder.Version() ); - _encodedChallenge = new byte[_challengeDecoder.EncodedChallengeLength()]; - _challengeDecoder.GetEncodedChallenge( - _encodedChallenge, - 0, - _challengeDecoder.EncodedChallengeLength() - ); + int encodedChallengeLength = _challengeDecoder.EncodedChallengeLength(); + _encodedChallenge = + 0 == encodedChallengeLength ? Array.Empty() : new byte[encodedChallengeLength]; + _challengeDecoder.GetEncodedChallenge(_encodedChallenge, 0, encodedChallengeLength); _clusterSessionId = _challengeDecoder.ClusterSessionId(); _correlationId = _challengeDecoder.CorrelationId(); diff --git a/src/Adaptive.Cluster/Codecs/AddPassiveMemberDecoder.cs b/src/Adaptive.Cluster/Codecs/AddPassiveMemberDecoder.cs index b0dd364e..03533373 100644 --- a/src/Adaptive.Cluster/Codecs/AddPassiveMemberDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/AddPassiveMemberDecoder.cs @@ -12,7 +12,7 @@ public class AddPassiveMemberDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 70; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AddPassiveMemberDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AddPassiveMemberEncoder.cs b/src/Adaptive.Cluster/Codecs/AddPassiveMemberEncoder.cs index 0f16293d..1f72211e 100644 --- a/src/Adaptive.Cluster/Codecs/AddPassiveMemberEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/AddPassiveMemberEncoder.cs @@ -12,7 +12,7 @@ public class AddPassiveMemberEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 70; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AddPassiveMemberEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AdminRequestDecoder.cs b/src/Adaptive.Cluster/Codecs/AdminRequestDecoder.cs index 9bf4e3ce..03a19f4b 100644 --- a/src/Adaptive.Cluster/Codecs/AdminRequestDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/AdminRequestDecoder.cs @@ -12,7 +12,7 @@ public class AdminRequestDecoder public const ushort BLOCK_LENGTH = 28; public const ushort TEMPLATE_ID = 26; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AdminRequestDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AdminRequestEncoder.cs b/src/Adaptive.Cluster/Codecs/AdminRequestEncoder.cs index 672256b1..c8917373 100644 --- a/src/Adaptive.Cluster/Codecs/AdminRequestEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/AdminRequestEncoder.cs @@ -12,7 +12,7 @@ public class AdminRequestEncoder public const ushort BLOCK_LENGTH = 28; public const ushort TEMPLATE_ID = 26; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AdminRequestEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AdminResponseDecoder.cs b/src/Adaptive.Cluster/Codecs/AdminResponseDecoder.cs index 12f54ba9..a5923e4f 100644 --- a/src/Adaptive.Cluster/Codecs/AdminResponseDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/AdminResponseDecoder.cs @@ -12,7 +12,7 @@ public class AdminResponseDecoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 27; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AdminResponseDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AdminResponseEncoder.cs b/src/Adaptive.Cluster/Codecs/AdminResponseEncoder.cs index 522883d9..22c041a8 100644 --- a/src/Adaptive.Cluster/Codecs/AdminResponseEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/AdminResponseEncoder.cs @@ -12,7 +12,7 @@ public class AdminResponseEncoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 27; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AdminResponseEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AppendPositionDecoder.cs b/src/Adaptive.Cluster/Codecs/AppendPositionDecoder.cs index 612ec241..f430bc32 100644 --- a/src/Adaptive.Cluster/Codecs/AppendPositionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/AppendPositionDecoder.cs @@ -12,7 +12,7 @@ public class AppendPositionDecoder public const ushort BLOCK_LENGTH = 21; public const ushort TEMPLATE_ID = 54; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AppendPositionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/AppendPositionEncoder.cs b/src/Adaptive.Cluster/Codecs/AppendPositionEncoder.cs index e7adaf32..5b92e7ff 100644 --- a/src/Adaptive.Cluster/Codecs/AppendPositionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/AppendPositionEncoder.cs @@ -12,7 +12,7 @@ public class AppendPositionEncoder public const ushort BLOCK_LENGTH = 21; public const ushort TEMPLATE_ID = 54; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private AppendPositionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/BackupQueryDecoder.cs b/src/Adaptive.Cluster/Codecs/BackupQueryDecoder.cs index 9a3728e6..ce555390 100644 --- a/src/Adaptive.Cluster/Codecs/BackupQueryDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/BackupQueryDecoder.cs @@ -9,10 +9,10 @@ namespace Adaptive.Cluster.Codecs { public class BackupQueryDecoder { - public const ushort BLOCK_LENGTH = 16; + public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 77; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private BackupQueryDecoder _parentMessage; private IDirectBuffer _buffer; @@ -249,6 +249,59 @@ public int Version() return _buffer.GetInt(_offset + 12, ByteOrder.LittleEndian); } + public static int LogPositionId() + { + return 6; + } + + public static int LogPositionSinceVersion() + { + return 16; + } + + public static int LogPositionEncodingOffset() + { + return 16; + } + + public static int LogPositionEncodingLength() + { + return 8; + } + + public static string LogPositionMetaAttribute(MetaAttribute metaAttribute) + { + switch (metaAttribute) + { + case MetaAttribute.EPOCH: return "unix"; + case MetaAttribute.TIME_UNIT: return "nanosecond"; + case MetaAttribute.SEMANTIC_TYPE: return ""; + case MetaAttribute.PRESENCE: return "optional"; + } + + return ""; + } + + public static long LogPositionNullValue() + { + return -9223372036854775808L; + } + + public static long LogPositionMinValue() + { + return -9223372036854775807L; + } + + public static long LogPositionMaxValue() + { + return 9223372036854775807L; + } + + public long LogPosition() + { + return _buffer.GetLong(_offset + 16, ByteOrder.LittleEndian); + } + public static int ResponseChannelId() { @@ -427,7 +480,12 @@ public StringBuilder AppendTo(StringBuilder builder) builder.Append("Version="); builder.Append(Version()); builder.Append('|'); - //Token{signal=BEGIN_VAR_DATA, name='responseChannel', referencedName='null', description='null', id=4, version=0, deprecated=0, encodedLength=0, offset=16, componentTokenCount=6, encoding=Encoding{presence=REQUIRED, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + //Token{signal=BEGIN_FIELD, name='logPosition', referencedName='null', description='null', id=6, version=16, deprecated=0, encodedLength=0, offset=16, componentTokenCount=3, encoding=Encoding{presence=OPTIONAL, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + //Token{signal=ENCODING, name='int64', referencedName='null', description='null', id=-1, version=0, deprecated=0, encodedLength=8, offset=16, componentTokenCount=1, encoding=Encoding{presence=OPTIONAL, primitiveType=INT64, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + builder.Append("LogPosition="); + builder.Append(LogPosition()); + builder.Append('|'); + //Token{signal=BEGIN_VAR_DATA, name='responseChannel', referencedName='null', description='null', id=4, version=0, deprecated=0, encodedLength=0, offset=24, componentTokenCount=6, encoding=Encoding{presence=REQUIRED, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} builder.Append("ResponseChannel="); builder.Append(ResponseChannel()); builder.Append('|'); diff --git a/src/Adaptive.Cluster/Codecs/BackupQueryEncoder.cs b/src/Adaptive.Cluster/Codecs/BackupQueryEncoder.cs index fd036e2c..c05548b5 100644 --- a/src/Adaptive.Cluster/Codecs/BackupQueryEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/BackupQueryEncoder.cs @@ -9,10 +9,10 @@ namespace Adaptive.Cluster.Codecs { public class BackupQueryEncoder { - public const ushort BLOCK_LENGTH = 16; + public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 77; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private BackupQueryEncoder _parentMessage; private IMutableDirectBuffer _buffer; @@ -192,6 +192,38 @@ public BackupQueryEncoder Version(int value) } + public static int LogPositionEncodingOffset() + { + return 16; + } + + public static int LogPositionEncodingLength() + { + return 8; + } + + public static long LogPositionNullValue() + { + return -9223372036854775808L; + } + + public static long LogPositionMinValue() + { + return -9223372036854775807L; + } + + public static long LogPositionMaxValue() + { + return 9223372036854775807L; + } + + public BackupQueryEncoder LogPosition(long value) + { + _buffer.PutLong(_offset + 16, value, ByteOrder.LittleEndian); + return this; + } + + public static int ResponseChannelId() { return 4; diff --git a/src/Adaptive.Cluster/Codecs/BackupResponseDecoder.cs b/src/Adaptive.Cluster/Codecs/BackupResponseDecoder.cs index da6b5bb3..05cee33b 100644 --- a/src/Adaptive.Cluster/Codecs/BackupResponseDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/BackupResponseDecoder.cs @@ -12,7 +12,7 @@ public class BackupResponseDecoder public const ushort BLOCK_LENGTH = 60; public const ushort TEMPLATE_ID = 78; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private BackupResponseDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/BackupResponseEncoder.cs b/src/Adaptive.Cluster/Codecs/BackupResponseEncoder.cs index e0c4315f..d3a467f6 100644 --- a/src/Adaptive.Cluster/Codecs/BackupResponseEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/BackupResponseEncoder.cs @@ -12,7 +12,7 @@ public class BackupResponseEncoder public const ushort BLOCK_LENGTH = 60; public const ushort TEMPLATE_ID = 78; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private BackupResponseEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CancelTimerDecoder.cs b/src/Adaptive.Cluster/Codecs/CancelTimerDecoder.cs index b6d97a12..cff58750 100644 --- a/src/Adaptive.Cluster/Codecs/CancelTimerDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/CancelTimerDecoder.cs @@ -12,7 +12,7 @@ public class CancelTimerDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 32; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CancelTimerDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CancelTimerEncoder.cs b/src/Adaptive.Cluster/Codecs/CancelTimerEncoder.cs index 0ba50f65..b9e0fa08 100644 --- a/src/Adaptive.Cluster/Codecs/CancelTimerEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/CancelTimerEncoder.cs @@ -12,7 +12,7 @@ public class CancelTimerEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 32; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CancelTimerEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CanvassPositionDecoder.cs b/src/Adaptive.Cluster/Codecs/CanvassPositionDecoder.cs index 0a20ba03..df663d52 100644 --- a/src/Adaptive.Cluster/Codecs/CanvassPositionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/CanvassPositionDecoder.cs @@ -12,7 +12,7 @@ public class CanvassPositionDecoder public const ushort BLOCK_LENGTH = 32; public const ushort TEMPLATE_ID = 50; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CanvassPositionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CanvassPositionEncoder.cs b/src/Adaptive.Cluster/Codecs/CanvassPositionEncoder.cs index 1ff81929..3f16f42e 100644 --- a/src/Adaptive.Cluster/Codecs/CanvassPositionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/CanvassPositionEncoder.cs @@ -12,7 +12,7 @@ public class CanvassPositionEncoder public const ushort BLOCK_LENGTH = 32; public const ushort TEMPLATE_ID = 50; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CanvassPositionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CatchupPositionDecoder.cs b/src/Adaptive.Cluster/Codecs/CatchupPositionDecoder.cs index f1c6a8ac..f33c6351 100644 --- a/src/Adaptive.Cluster/Codecs/CatchupPositionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/CatchupPositionDecoder.cs @@ -12,7 +12,7 @@ public class CatchupPositionDecoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 56; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CatchupPositionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CatchupPositionEncoder.cs b/src/Adaptive.Cluster/Codecs/CatchupPositionEncoder.cs index f7b7d42a..b4e8e179 100644 --- a/src/Adaptive.Cluster/Codecs/CatchupPositionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/CatchupPositionEncoder.cs @@ -12,7 +12,7 @@ public class CatchupPositionEncoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 56; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CatchupPositionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ChallengeDecoder.cs b/src/Adaptive.Cluster/Codecs/ChallengeDecoder.cs index 24ad14d5..a46b8b39 100644 --- a/src/Adaptive.Cluster/Codecs/ChallengeDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ChallengeDecoder.cs @@ -12,7 +12,7 @@ public class ChallengeDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 7; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ChallengeDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ChallengeEncoder.cs b/src/Adaptive.Cluster/Codecs/ChallengeEncoder.cs index 4be0486f..8fdc814b 100644 --- a/src/Adaptive.Cluster/Codecs/ChallengeEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ChallengeEncoder.cs @@ -12,7 +12,7 @@ public class ChallengeEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 7; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ChallengeEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ChallengeResponseDecoder.cs b/src/Adaptive.Cluster/Codecs/ChallengeResponseDecoder.cs index 98ecb915..ce2e20a3 100644 --- a/src/Adaptive.Cluster/Codecs/ChallengeResponseDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ChallengeResponseDecoder.cs @@ -12,7 +12,7 @@ public class ChallengeResponseDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 8; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ChallengeResponseDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ChallengeResponseEncoder.cs b/src/Adaptive.Cluster/Codecs/ChallengeResponseEncoder.cs index 9c607a85..e05fc7a9 100644 --- a/src/Adaptive.Cluster/Codecs/ChallengeResponseEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ChallengeResponseEncoder.cs @@ -12,7 +12,7 @@ public class ChallengeResponseEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 8; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ChallengeResponseEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClientSessionDecoder.cs b/src/Adaptive.Cluster/Codecs/ClientSessionDecoder.cs index 83d912e1..7b1370b6 100644 --- a/src/Adaptive.Cluster/Codecs/ClientSessionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClientSessionDecoder.cs @@ -12,7 +12,7 @@ public class ClientSessionDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 102; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClientSessionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClientSessionEncoder.cs b/src/Adaptive.Cluster/Codecs/ClientSessionEncoder.cs index c0248fad..07ec5dea 100644 --- a/src/Adaptive.Cluster/Codecs/ClientSessionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClientSessionEncoder.cs @@ -12,7 +12,7 @@ public class ClientSessionEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 102; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClientSessionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CloseSessionDecoder.cs b/src/Adaptive.Cluster/Codecs/CloseSessionDecoder.cs index eeb97395..8a03fa02 100644 --- a/src/Adaptive.Cluster/Codecs/CloseSessionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/CloseSessionDecoder.cs @@ -12,7 +12,7 @@ public class CloseSessionDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 30; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CloseSessionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CloseSessionEncoder.cs b/src/Adaptive.Cluster/Codecs/CloseSessionEncoder.cs index 76345e95..e88a153a 100644 --- a/src/Adaptive.Cluster/Codecs/CloseSessionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/CloseSessionEncoder.cs @@ -12,7 +12,7 @@ public class CloseSessionEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 30; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CloseSessionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterActionRequestDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterActionRequestDecoder.cs index 82fcc080..1374851b 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterActionRequestDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterActionRequestDecoder.cs @@ -12,7 +12,7 @@ public class ClusterActionRequestDecoder public const ushort BLOCK_LENGTH = 32; public const ushort TEMPLATE_ID = 23; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterActionRequestDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterActionRequestEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterActionRequestEncoder.cs index ed45940d..2b8ca9c2 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterActionRequestEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterActionRequestEncoder.cs @@ -12,7 +12,7 @@ public class ClusterActionRequestEncoder public const ushort BLOCK_LENGTH = 32; public const ushort TEMPLATE_ID = 23; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterActionRequestEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersChangeDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersChangeDecoder.cs index 2de82e20..925e886d 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersChangeDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersChangeDecoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersChangeDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 71; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersChangeDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersChangeEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersChangeEncoder.cs index 828e5842..371e4097 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersChangeEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersChangeEncoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersChangeEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 71; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersChangeEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersDecoder.cs index 5bd4b0f4..f0898338 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersDecoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 106; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersEncoder.cs index f93f9237..36f05804 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersEncoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 106; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseDecoder.cs index 8a41c8f7..ddf07ee2 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseDecoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersExtendedResponseDecoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 43; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersExtendedResponseDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseEncoder.cs index 7046ee5d..eeaf6808 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersExtendedResponseEncoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersExtendedResponseEncoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 43; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersExtendedResponseEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersQueryDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersQueryDecoder.cs index a9b7a9d5..e79a13a9 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersQueryDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersQueryDecoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersQueryDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 34; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersQueryDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersQueryEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersQueryEncoder.cs index 080d28e7..7f19bf39 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersQueryEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersQueryEncoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersQueryEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 34; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersQueryEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersResponseDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersResponseDecoder.cs index 7ac2e6c0..6b3fa7e6 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersResponseDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersResponseDecoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersResponseDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 41; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersResponseDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterMembersResponseEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterMembersResponseEncoder.cs index ec9176b7..017f8062 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterMembersResponseEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterMembersResponseEncoder.cs @@ -12,7 +12,7 @@ public class ClusterMembersResponseEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 41; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterMembersResponseEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterSessionDecoder.cs b/src/Adaptive.Cluster/Codecs/ClusterSessionDecoder.cs index a792880e..ac0fb871 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterSessionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterSessionDecoder.cs @@ -12,7 +12,7 @@ public class ClusterSessionDecoder public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 103; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterSessionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ClusterSessionEncoder.cs b/src/Adaptive.Cluster/Codecs/ClusterSessionEncoder.cs index a008040c..b895a733 100644 --- a/src/Adaptive.Cluster/Codecs/ClusterSessionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ClusterSessionEncoder.cs @@ -12,7 +12,7 @@ public class ClusterSessionEncoder public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 103; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ClusterSessionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CommitPositionDecoder.cs b/src/Adaptive.Cluster/Codecs/CommitPositionDecoder.cs index 31780eaa..bf6f1506 100644 --- a/src/Adaptive.Cluster/Codecs/CommitPositionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/CommitPositionDecoder.cs @@ -12,7 +12,7 @@ public class CommitPositionDecoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 55; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CommitPositionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/CommitPositionEncoder.cs b/src/Adaptive.Cluster/Codecs/CommitPositionEncoder.cs index 773f4031..5d322739 100644 --- a/src/Adaptive.Cluster/Codecs/CommitPositionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/CommitPositionEncoder.cs @@ -12,7 +12,7 @@ public class CommitPositionEncoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 55; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private CommitPositionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ConsensusModuleDecoder.cs b/src/Adaptive.Cluster/Codecs/ConsensusModuleDecoder.cs index f23d46bc..6e19ce86 100644 --- a/src/Adaptive.Cluster/Codecs/ConsensusModuleDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ConsensusModuleDecoder.cs @@ -12,7 +12,7 @@ public class ConsensusModuleDecoder public const ushort BLOCK_LENGTH = 28; public const ushort TEMPLATE_ID = 105; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ConsensusModuleDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ConsensusModuleEncoder.cs b/src/Adaptive.Cluster/Codecs/ConsensusModuleEncoder.cs index b4f60125..dd90a065 100644 --- a/src/Adaptive.Cluster/Codecs/ConsensusModuleEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ConsensusModuleEncoder.cs @@ -12,7 +12,7 @@ public class ConsensusModuleEncoder public const ushort BLOCK_LENGTH = 28; public const ushort TEMPLATE_ID = 105; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ConsensusModuleEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/HeartbeatRequestDecoder.cs b/src/Adaptive.Cluster/Codecs/HeartbeatRequestDecoder.cs index 492c9dcd..b3594dce 100644 --- a/src/Adaptive.Cluster/Codecs/HeartbeatRequestDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/HeartbeatRequestDecoder.cs @@ -12,7 +12,7 @@ public class HeartbeatRequestDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 79; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private HeartbeatRequestDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/HeartbeatRequestEncoder.cs b/src/Adaptive.Cluster/Codecs/HeartbeatRequestEncoder.cs index 13fc9f09..74dab2ef 100644 --- a/src/Adaptive.Cluster/Codecs/HeartbeatRequestEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/HeartbeatRequestEncoder.cs @@ -12,7 +12,7 @@ public class HeartbeatRequestEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 79; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private HeartbeatRequestEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/HeartbeatResponseDecoder.cs b/src/Adaptive.Cluster/Codecs/HeartbeatResponseDecoder.cs index 747067fc..7625da05 100644 --- a/src/Adaptive.Cluster/Codecs/HeartbeatResponseDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/HeartbeatResponseDecoder.cs @@ -12,7 +12,7 @@ public class HeartbeatResponseDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 80; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private HeartbeatResponseDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/HeartbeatResponseEncoder.cs b/src/Adaptive.Cluster/Codecs/HeartbeatResponseEncoder.cs index 7a9401c4..8f8e6b6e 100644 --- a/src/Adaptive.Cluster/Codecs/HeartbeatResponseEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/HeartbeatResponseEncoder.cs @@ -12,7 +12,7 @@ public class HeartbeatResponseEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 80; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private HeartbeatResponseEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/JoinClusterDecoder.cs b/src/Adaptive.Cluster/Codecs/JoinClusterDecoder.cs index c6a66dff..a222247c 100644 --- a/src/Adaptive.Cluster/Codecs/JoinClusterDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/JoinClusterDecoder.cs @@ -12,7 +12,7 @@ public class JoinClusterDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 74; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private JoinClusterDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/JoinClusterEncoder.cs b/src/Adaptive.Cluster/Codecs/JoinClusterEncoder.cs index fc776b73..23e16eb7 100644 --- a/src/Adaptive.Cluster/Codecs/JoinClusterEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/JoinClusterEncoder.cs @@ -12,7 +12,7 @@ public class JoinClusterEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 74; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private JoinClusterEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/JoinLogDecoder.cs b/src/Adaptive.Cluster/Codecs/JoinLogDecoder.cs index c5219bca..136ebadc 100644 --- a/src/Adaptive.Cluster/Codecs/JoinLogDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/JoinLogDecoder.cs @@ -9,10 +9,10 @@ namespace Adaptive.Cluster.Codecs { public class JoinLogDecoder { - public const ushort BLOCK_LENGTH = 36; + public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 40; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private JoinLogDecoder _parentMessage; private IDirectBuffer _buffer; @@ -451,6 +451,47 @@ public int Role() } + public static int IsStandbyId() + { + return 9; + } + + public static int IsStandbySinceVersion() + { + return 16; + } + + public static int IsStandbyEncodingOffset() + { + return 36; + } + + public static int IsStandbyEncodingLength() + { + return 4; + } + + public static string IsStandbyMetaAttribute(MetaAttribute metaAttribute) + { + switch (metaAttribute) + { + case MetaAttribute.EPOCH: return "unix"; + case MetaAttribute.TIME_UNIT: return "nanosecond"; + case MetaAttribute.SEMANTIC_TYPE: return ""; + case MetaAttribute.PRESENCE: return "optional"; + } + + return ""; + } + + public BooleanType IsStandby() + { + if (_actingVersion < 16) return BooleanType.NULL_VALUE; + + return (BooleanType)_buffer.GetInt(_offset + 36, ByteOrder.LittleEndian); + } + + public static int LogChannelId() { return 8; @@ -590,7 +631,12 @@ public StringBuilder AppendTo(StringBuilder builder) builder.Append("Role="); builder.Append(Role()); builder.Append('|'); - //Token{signal=BEGIN_VAR_DATA, name='logChannel', referencedName='null', description='null', id=8, version=0, deprecated=0, encodedLength=0, offset=36, componentTokenCount=6, encoding=Encoding{presence=REQUIRED, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + //Token{signal=BEGIN_FIELD, name='isStandby', referencedName='null', description='null', id=9, version=16, deprecated=0, encodedLength=0, offset=36, componentTokenCount=6, encoding=Encoding{presence=OPTIONAL, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + //Token{signal=BEGIN_ENUM, name='BooleanType', referencedName='null', description='Language independent boolean type.', id=-1, version=16, deprecated=0, encodedLength=4, offset=36, componentTokenCount=4, encoding=Encoding{presence=REQUIRED, primitiveType=INT32, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + builder.Append("IsStandby="); + builder.Append(IsStandby()); + builder.Append('|'); + //Token{signal=BEGIN_VAR_DATA, name='logChannel', referencedName='null', description='null', id=8, version=0, deprecated=0, encodedLength=0, offset=40, componentTokenCount=6, encoding=Encoding{presence=REQUIRED, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} builder.Append("LogChannel="); builder.Append(LogChannel()); diff --git a/src/Adaptive.Cluster/Codecs/JoinLogEncoder.cs b/src/Adaptive.Cluster/Codecs/JoinLogEncoder.cs index 87ec32b0..f787a8f5 100644 --- a/src/Adaptive.Cluster/Codecs/JoinLogEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/JoinLogEncoder.cs @@ -9,10 +9,10 @@ namespace Adaptive.Cluster.Codecs { public class JoinLogEncoder { - public const ushort BLOCK_LENGTH = 36; + public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 40; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private JoinLogEncoder _parentMessage; private IMutableDirectBuffer _buffer; @@ -304,6 +304,22 @@ public JoinLogEncoder Role(int value) } + public static int IsStandbyEncodingOffset() + { + return 36; + } + + public static int IsStandbyEncodingLength() + { + return 4; + } + + public JoinLogEncoder IsStandby(BooleanType value) + { + _buffer.PutInt(_offset + 36, (int)value, ByteOrder.LittleEndian); + return this; + } + public static int LogChannelId() { return 8; diff --git a/src/Adaptive.Cluster/Codecs/MembershipChangeEventDecoder.cs b/src/Adaptive.Cluster/Codecs/MembershipChangeEventDecoder.cs index 5a5daca0..ebc32170 100644 --- a/src/Adaptive.Cluster/Codecs/MembershipChangeEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/MembershipChangeEventDecoder.cs @@ -12,7 +12,7 @@ public class MembershipChangeEventDecoder public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 25; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private MembershipChangeEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/MembershipChangeEventEncoder.cs b/src/Adaptive.Cluster/Codecs/MembershipChangeEventEncoder.cs index 3cea8818..e8f07825 100644 --- a/src/Adaptive.Cluster/Codecs/MembershipChangeEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/MembershipChangeEventEncoder.cs @@ -12,7 +12,7 @@ public class MembershipChangeEventEncoder public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 25; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private MembershipChangeEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/NewLeaderEventDecoder.cs b/src/Adaptive.Cluster/Codecs/NewLeaderEventDecoder.cs index bd173896..e57e709d 100644 --- a/src/Adaptive.Cluster/Codecs/NewLeaderEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/NewLeaderEventDecoder.cs @@ -12,7 +12,7 @@ public class NewLeaderEventDecoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 6; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private NewLeaderEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/NewLeaderEventEncoder.cs b/src/Adaptive.Cluster/Codecs/NewLeaderEventEncoder.cs index b9078454..8de3ae82 100644 --- a/src/Adaptive.Cluster/Codecs/NewLeaderEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/NewLeaderEventEncoder.cs @@ -12,7 +12,7 @@ public class NewLeaderEventEncoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 6; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private NewLeaderEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/NewLeadershipTermDecoder.cs b/src/Adaptive.Cluster/Codecs/NewLeadershipTermDecoder.cs index a2a682c9..30718a72 100644 --- a/src/Adaptive.Cluster/Codecs/NewLeadershipTermDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/NewLeadershipTermDecoder.cs @@ -9,10 +9,10 @@ namespace Adaptive.Cluster.Codecs { public class NewLeadershipTermDecoder { - public const ushort BLOCK_LENGTH = 88; + public const ushort BLOCK_LENGTH = 96; public const ushort TEMPLATE_ID = 53; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private NewLeadershipTermDecoder _parentMessage; private IDirectBuffer _buffer; @@ -775,6 +775,60 @@ public BooleanType IsStartup() } + public static int CommitPositionId() + { + return 14; + } + + public static int CommitPositionSinceVersion() + { + return 15; + } + + public static int CommitPositionEncodingOffset() + { + return 88; + } + + public static int CommitPositionEncodingLength() + { + return 8; + } + + public static string CommitPositionMetaAttribute(MetaAttribute metaAttribute) + { + switch (metaAttribute) + { + case MetaAttribute.EPOCH: return "unix"; + case MetaAttribute.TIME_UNIT: return "nanosecond"; + case MetaAttribute.SEMANTIC_TYPE: return ""; + case MetaAttribute.PRESENCE: return "required"; + } + + return ""; + } + + public static long CommitPositionNullValue() + { + return -9223372036854775808L; + } + + public static long CommitPositionMinValue() + { + return -9223372036854775807L; + } + + public static long CommitPositionMaxValue() + { + return 9223372036854775807L; + } + + public long CommitPosition() + { + return _buffer.GetLong(_offset + 88, ByteOrder.LittleEndian); + } + + public override string ToString() { @@ -868,6 +922,11 @@ public StringBuilder AppendTo(StringBuilder builder) //Token{signal=BEGIN_ENUM, name='BooleanType', referencedName='null', description='Language independent boolean type.', id=-1, version=0, deprecated=0, encodedLength=4, offset=84, componentTokenCount=4, encoding=Encoding{presence=REQUIRED, primitiveType=INT32, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='null', timeUnit=null, semanticType='null'}} builder.Append("IsStartup="); builder.Append(IsStartup()); + builder.Append('|'); + //Token{signal=BEGIN_FIELD, name='commitPosition', referencedName='null', description='null', id=14, version=15, deprecated=0, encodedLength=0, offset=88, componentTokenCount=3, encoding=Encoding{presence=REQUIRED, primitiveType=null, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + //Token{signal=ENCODING, name='int64', referencedName='null', description='null', id=-1, version=0, deprecated=0, encodedLength=8, offset=88, componentTokenCount=1, encoding=Encoding{presence=REQUIRED, primitiveType=INT64, byteOrder=LITTLE_ENDIAN, minValue=null, maxValue=null, nullValue=null, constValue=null, characterEncoding='null', epoch='unix', timeUnit=nanosecond, semanticType='null'}} + builder.Append("CommitPosition="); + builder.Append(CommitPosition()); Limit(originalLimit); diff --git a/src/Adaptive.Cluster/Codecs/NewLeadershipTermEncoder.cs b/src/Adaptive.Cluster/Codecs/NewLeadershipTermEncoder.cs index 07b69778..8e436686 100644 --- a/src/Adaptive.Cluster/Codecs/NewLeadershipTermEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/NewLeadershipTermEncoder.cs @@ -9,10 +9,10 @@ namespace Adaptive.Cluster.Codecs { public class NewLeadershipTermEncoder { - public const ushort BLOCK_LENGTH = 88; + public const ushort BLOCK_LENGTH = 96; public const ushort TEMPLATE_ID = 53; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private NewLeadershipTermEncoder _parentMessage; private IMutableDirectBuffer _buffer; @@ -496,6 +496,36 @@ public NewLeadershipTermEncoder IsStartup(BooleanType value) return this; } + public static int CommitPositionEncodingOffset() + { + return 88; + } + + public static int CommitPositionEncodingLength() + { + return 8; + } + + public static long CommitPositionNullValue() + { + return -9223372036854775808L; + } + + public static long CommitPositionMinValue() + { + return -9223372036854775807L; + } + + public static long CommitPositionMaxValue() + { + return 9223372036854775807L; + } + + public NewLeadershipTermEncoder CommitPosition(long value) + { + _buffer.PutLong(_offset + 88, value, ByteOrder.LittleEndian); + return this; + } public override string ToString() { diff --git a/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventDecoder.cs b/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventDecoder.cs index a345aae3..f49eefd7 100644 --- a/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventDecoder.cs @@ -12,7 +12,7 @@ public class NewLeadershipTermEventDecoder public const ushort BLOCK_LENGTH = 48; public const ushort TEMPLATE_ID = 24; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private NewLeadershipTermEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventEncoder.cs b/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventEncoder.cs index cb79c3ad..1e2f0962 100644 --- a/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/NewLeadershipTermEventEncoder.cs @@ -12,7 +12,7 @@ public class NewLeadershipTermEventEncoder public const ushort BLOCK_LENGTH = 48; public const ushort TEMPLATE_ID = 24; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private NewLeadershipTermEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/PendingMessageTrackerDecoder.cs b/src/Adaptive.Cluster/Codecs/PendingMessageTrackerDecoder.cs index 5fa72a34..1c83bc63 100644 --- a/src/Adaptive.Cluster/Codecs/PendingMessageTrackerDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/PendingMessageTrackerDecoder.cs @@ -12,7 +12,7 @@ public class PendingMessageTrackerDecoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 107; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private PendingMessageTrackerDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/PendingMessageTrackerEncoder.cs b/src/Adaptive.Cluster/Codecs/PendingMessageTrackerEncoder.cs index 1c18b214..f01d1297 100644 --- a/src/Adaptive.Cluster/Codecs/PendingMessageTrackerEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/PendingMessageTrackerEncoder.cs @@ -12,7 +12,7 @@ public class PendingMessageTrackerEncoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 107; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private PendingMessageTrackerEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/RemoveMemberDecoder.cs b/src/Adaptive.Cluster/Codecs/RemoveMemberDecoder.cs index 213325c5..55b356fa 100644 --- a/src/Adaptive.Cluster/Codecs/RemoveMemberDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/RemoveMemberDecoder.cs @@ -12,7 +12,7 @@ public class RemoveMemberDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 35; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private RemoveMemberDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/RemoveMemberEncoder.cs b/src/Adaptive.Cluster/Codecs/RemoveMemberEncoder.cs index 96c66b72..f2c1806b 100644 --- a/src/Adaptive.Cluster/Codecs/RemoveMemberEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/RemoveMemberEncoder.cs @@ -12,7 +12,7 @@ public class RemoveMemberEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 35; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private RemoveMemberEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/RequestServiceAckDecoder.cs b/src/Adaptive.Cluster/Codecs/RequestServiceAckDecoder.cs index cebfb1d3..42385c36 100644 --- a/src/Adaptive.Cluster/Codecs/RequestServiceAckDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/RequestServiceAckDecoder.cs @@ -12,7 +12,7 @@ public class RequestServiceAckDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 108; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private RequestServiceAckDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/RequestServiceAckEncoder.cs b/src/Adaptive.Cluster/Codecs/RequestServiceAckEncoder.cs index e560c7c3..a9dc0e2a 100644 --- a/src/Adaptive.Cluster/Codecs/RequestServiceAckEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/RequestServiceAckEncoder.cs @@ -12,7 +12,7 @@ public class RequestServiceAckEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 108; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private RequestServiceAckEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/RequestVoteDecoder.cs b/src/Adaptive.Cluster/Codecs/RequestVoteDecoder.cs index cf8984a2..ba22bd33 100644 --- a/src/Adaptive.Cluster/Codecs/RequestVoteDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/RequestVoteDecoder.cs @@ -12,7 +12,7 @@ public class RequestVoteDecoder public const ushort BLOCK_LENGTH = 32; public const ushort TEMPLATE_ID = 51; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private RequestVoteDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/RequestVoteEncoder.cs b/src/Adaptive.Cluster/Codecs/RequestVoteEncoder.cs index 2e5bff41..79f67752 100644 --- a/src/Adaptive.Cluster/Codecs/RequestVoteEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/RequestVoteEncoder.cs @@ -12,7 +12,7 @@ public class RequestVoteEncoder public const ushort BLOCK_LENGTH = 32; public const ushort TEMPLATE_ID = 51; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private RequestVoteEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ScheduleTimerDecoder.cs b/src/Adaptive.Cluster/Codecs/ScheduleTimerDecoder.cs index 18aee7f9..2424d692 100644 --- a/src/Adaptive.Cluster/Codecs/ScheduleTimerDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ScheduleTimerDecoder.cs @@ -12,7 +12,7 @@ public class ScheduleTimerDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 31; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ScheduleTimerDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ScheduleTimerEncoder.cs b/src/Adaptive.Cluster/Codecs/ScheduleTimerEncoder.cs index ab8657e1..6c586ab8 100644 --- a/src/Adaptive.Cluster/Codecs/ScheduleTimerEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ScheduleTimerEncoder.cs @@ -12,7 +12,7 @@ public class ScheduleTimerEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 31; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ScheduleTimerEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ServiceAckDecoder.cs b/src/Adaptive.Cluster/Codecs/ServiceAckDecoder.cs index 1c156ec5..9b9df769 100644 --- a/src/Adaptive.Cluster/Codecs/ServiceAckDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ServiceAckDecoder.cs @@ -12,7 +12,7 @@ public class ServiceAckDecoder public const ushort BLOCK_LENGTH = 36; public const ushort TEMPLATE_ID = 33; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ServiceAckDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ServiceAckEncoder.cs b/src/Adaptive.Cluster/Codecs/ServiceAckEncoder.cs index 703082bb..94f19636 100644 --- a/src/Adaptive.Cluster/Codecs/ServiceAckEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ServiceAckEncoder.cs @@ -12,7 +12,7 @@ public class ServiceAckEncoder public const ushort BLOCK_LENGTH = 36; public const ushort TEMPLATE_ID = 33; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ServiceAckEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionDecoder.cs b/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionDecoder.cs index e3a89ebd..4819bb4c 100644 --- a/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionDecoder.cs @@ -12,7 +12,7 @@ public class ServiceTerminationPositionDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 42; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ServiceTerminationPositionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionEncoder.cs b/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionEncoder.cs index 2459f256..c6537159 100644 --- a/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/ServiceTerminationPositionEncoder.cs @@ -12,7 +12,7 @@ public class ServiceTerminationPositionEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 42; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private ServiceTerminationPositionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionCloseEventDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionCloseEventDecoder.cs index 42037079..ce1140e3 100644 --- a/src/Adaptive.Cluster/Codecs/SessionCloseEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionCloseEventDecoder.cs @@ -12,7 +12,7 @@ public class SessionCloseEventDecoder public const ushort BLOCK_LENGTH = 28; public const ushort TEMPLATE_ID = 22; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionCloseEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionCloseEventEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionCloseEventEncoder.cs index a72c0131..337eaeaf 100644 --- a/src/Adaptive.Cluster/Codecs/SessionCloseEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionCloseEventEncoder.cs @@ -12,7 +12,7 @@ public class SessionCloseEventEncoder public const ushort BLOCK_LENGTH = 28; public const ushort TEMPLATE_ID = 22; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionCloseEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionCloseRequestDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionCloseRequestDecoder.cs index 6616da1c..ab4589ce 100644 --- a/src/Adaptive.Cluster/Codecs/SessionCloseRequestDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionCloseRequestDecoder.cs @@ -12,7 +12,7 @@ public class SessionCloseRequestDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 4; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionCloseRequestDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionCloseRequestEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionCloseRequestEncoder.cs index 7090f81c..53f61cd9 100644 --- a/src/Adaptive.Cluster/Codecs/SessionCloseRequestEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionCloseRequestEncoder.cs @@ -12,7 +12,7 @@ public class SessionCloseRequestEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 4; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionCloseRequestEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionConnectRequestDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionConnectRequestDecoder.cs index 833124cd..06c8816d 100644 --- a/src/Adaptive.Cluster/Codecs/SessionConnectRequestDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionConnectRequestDecoder.cs @@ -12,7 +12,7 @@ public class SessionConnectRequestDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 3; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionConnectRequestDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionConnectRequestEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionConnectRequestEncoder.cs index 1e5e7729..2ffd571a 100644 --- a/src/Adaptive.Cluster/Codecs/SessionConnectRequestEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionConnectRequestEncoder.cs @@ -12,7 +12,7 @@ public class SessionConnectRequestEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 3; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionConnectRequestEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionEventDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionEventDecoder.cs index a15ccd57..9409aa47 100644 --- a/src/Adaptive.Cluster/Codecs/SessionEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionEventDecoder.cs @@ -12,7 +12,7 @@ public class SessionEventDecoder public const ushort BLOCK_LENGTH = 44; public const ushort TEMPLATE_ID = 2; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionEventEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionEventEncoder.cs index a90ac8cb..a1e7ddf8 100644 --- a/src/Adaptive.Cluster/Codecs/SessionEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionEventEncoder.cs @@ -12,7 +12,7 @@ public class SessionEventEncoder public const ushort BLOCK_LENGTH = 44; public const ushort TEMPLATE_ID = 2; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionKeepAliveDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionKeepAliveDecoder.cs index 46d5eb6b..db8d4a3f 100644 --- a/src/Adaptive.Cluster/Codecs/SessionKeepAliveDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionKeepAliveDecoder.cs @@ -12,7 +12,7 @@ public class SessionKeepAliveDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 5; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionKeepAliveDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionKeepAliveEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionKeepAliveEncoder.cs index b60af0f6..5d51f925 100644 --- a/src/Adaptive.Cluster/Codecs/SessionKeepAliveEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionKeepAliveEncoder.cs @@ -12,7 +12,7 @@ public class SessionKeepAliveEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 5; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionKeepAliveEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionMessageHeaderDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionMessageHeaderDecoder.cs index c1eac68d..f55adb19 100644 --- a/src/Adaptive.Cluster/Codecs/SessionMessageHeaderDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionMessageHeaderDecoder.cs @@ -12,7 +12,7 @@ public class SessionMessageHeaderDecoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 1; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionMessageHeaderDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionMessageHeaderEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionMessageHeaderEncoder.cs index 55026353..4c043abc 100644 --- a/src/Adaptive.Cluster/Codecs/SessionMessageHeaderEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionMessageHeaderEncoder.cs @@ -12,7 +12,7 @@ public class SessionMessageHeaderEncoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 1; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionMessageHeaderEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionOpenEventDecoder.cs b/src/Adaptive.Cluster/Codecs/SessionOpenEventDecoder.cs index 25089b99..bba4bd2e 100644 --- a/src/Adaptive.Cluster/Codecs/SessionOpenEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionOpenEventDecoder.cs @@ -12,7 +12,7 @@ public class SessionOpenEventDecoder public const ushort BLOCK_LENGTH = 36; public const ushort TEMPLATE_ID = 21; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionOpenEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SessionOpenEventEncoder.cs b/src/Adaptive.Cluster/Codecs/SessionOpenEventEncoder.cs index f5862558..bb8af49b 100644 --- a/src/Adaptive.Cluster/Codecs/SessionOpenEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SessionOpenEventEncoder.cs @@ -12,7 +12,7 @@ public class SessionOpenEventEncoder public const ushort BLOCK_LENGTH = 36; public const ushort TEMPLATE_ID = 21; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SessionOpenEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SnapshotMarkerDecoder.cs b/src/Adaptive.Cluster/Codecs/SnapshotMarkerDecoder.cs index 38cc7527..4975c9c5 100644 --- a/src/Adaptive.Cluster/Codecs/SnapshotMarkerDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SnapshotMarkerDecoder.cs @@ -12,7 +12,7 @@ public class SnapshotMarkerDecoder public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 100; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SnapshotMarkerDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SnapshotMarkerEncoder.cs b/src/Adaptive.Cluster/Codecs/SnapshotMarkerEncoder.cs index 680e1c23..542a509a 100644 --- a/src/Adaptive.Cluster/Codecs/SnapshotMarkerEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SnapshotMarkerEncoder.cs @@ -12,7 +12,7 @@ public class SnapshotMarkerEncoder public const ushort BLOCK_LENGTH = 40; public const ushort TEMPLATE_ID = 100; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SnapshotMarkerEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryDecoder.cs b/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryDecoder.cs index 54b09e8e..0a0abb79 100644 --- a/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryDecoder.cs @@ -12,7 +12,7 @@ public class SnapshotRecordingQueryDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 72; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SnapshotRecordingQueryDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryEncoder.cs b/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryEncoder.cs index 4d19461a..7857d486 100644 --- a/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SnapshotRecordingQueryEncoder.cs @@ -12,7 +12,7 @@ public class SnapshotRecordingQueryEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 72; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SnapshotRecordingQueryEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SnapshotRecordingsDecoder.cs b/src/Adaptive.Cluster/Codecs/SnapshotRecordingsDecoder.cs index aa0fe041..19b1b9cd 100644 --- a/src/Adaptive.Cluster/Codecs/SnapshotRecordingsDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/SnapshotRecordingsDecoder.cs @@ -12,7 +12,7 @@ public class SnapshotRecordingsDecoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 73; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SnapshotRecordingsDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/SnapshotRecordingsEncoder.cs b/src/Adaptive.Cluster/Codecs/SnapshotRecordingsEncoder.cs index 89ab43e3..10b767f3 100644 --- a/src/Adaptive.Cluster/Codecs/SnapshotRecordingsEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/SnapshotRecordingsEncoder.cs @@ -12,7 +12,7 @@ public class SnapshotRecordingsEncoder public const ushort BLOCK_LENGTH = 8; public const ushort TEMPLATE_ID = 73; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private SnapshotRecordingsEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/StandbySnapshotDecoder.cs b/src/Adaptive.Cluster/Codecs/StandbySnapshotDecoder.cs index 6b06cefb..219cf6da 100644 --- a/src/Adaptive.Cluster/Codecs/StandbySnapshotDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/StandbySnapshotDecoder.cs @@ -12,7 +12,7 @@ public class StandbySnapshotDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 81; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private StandbySnapshotDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/StandbySnapshotEncoder.cs b/src/Adaptive.Cluster/Codecs/StandbySnapshotEncoder.cs index ba33d9c0..6b179aeb 100644 --- a/src/Adaptive.Cluster/Codecs/StandbySnapshotEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/StandbySnapshotEncoder.cs @@ -12,7 +12,7 @@ public class StandbySnapshotEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 81; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private StandbySnapshotEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/StopCatchupDecoder.cs b/src/Adaptive.Cluster/Codecs/StopCatchupDecoder.cs index 4895f27f..14529472 100644 --- a/src/Adaptive.Cluster/Codecs/StopCatchupDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/StopCatchupDecoder.cs @@ -12,7 +12,7 @@ public class StopCatchupDecoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 57; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private StopCatchupDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/StopCatchupEncoder.cs b/src/Adaptive.Cluster/Codecs/StopCatchupEncoder.cs index de26fbd2..27581c99 100644 --- a/src/Adaptive.Cluster/Codecs/StopCatchupEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/StopCatchupEncoder.cs @@ -12,7 +12,7 @@ public class StopCatchupEncoder public const ushort BLOCK_LENGTH = 12; public const ushort TEMPLATE_ID = 57; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private StopCatchupEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TerminationAckDecoder.cs b/src/Adaptive.Cluster/Codecs/TerminationAckDecoder.cs index 80bd35c3..2e75a11d 100644 --- a/src/Adaptive.Cluster/Codecs/TerminationAckDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/TerminationAckDecoder.cs @@ -12,7 +12,7 @@ public class TerminationAckDecoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 76; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TerminationAckDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TerminationAckEncoder.cs b/src/Adaptive.Cluster/Codecs/TerminationAckEncoder.cs index 83d6f885..6c22031f 100644 --- a/src/Adaptive.Cluster/Codecs/TerminationAckEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/TerminationAckEncoder.cs @@ -12,7 +12,7 @@ public class TerminationAckEncoder public const ushort BLOCK_LENGTH = 20; public const ushort TEMPLATE_ID = 76; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TerminationAckEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TerminationPositionDecoder.cs b/src/Adaptive.Cluster/Codecs/TerminationPositionDecoder.cs index 57dbffcf..7ef74c46 100644 --- a/src/Adaptive.Cluster/Codecs/TerminationPositionDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/TerminationPositionDecoder.cs @@ -12,7 +12,7 @@ public class TerminationPositionDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 75; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TerminationPositionDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TerminationPositionEncoder.cs b/src/Adaptive.Cluster/Codecs/TerminationPositionEncoder.cs index ea18f319..1a310956 100644 --- a/src/Adaptive.Cluster/Codecs/TerminationPositionEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/TerminationPositionEncoder.cs @@ -12,7 +12,7 @@ public class TerminationPositionEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 75; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TerminationPositionEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TimerDecoder.cs b/src/Adaptive.Cluster/Codecs/TimerDecoder.cs index 5f4a59ae..6bb7ac63 100644 --- a/src/Adaptive.Cluster/Codecs/TimerDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/TimerDecoder.cs @@ -12,7 +12,7 @@ public class TimerDecoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 104; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TimerDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TimerEncoder.cs b/src/Adaptive.Cluster/Codecs/TimerEncoder.cs index 33d529b8..0c4e2599 100644 --- a/src/Adaptive.Cluster/Codecs/TimerEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/TimerEncoder.cs @@ -12,7 +12,7 @@ public class TimerEncoder public const ushort BLOCK_LENGTH = 16; public const ushort TEMPLATE_ID = 104; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TimerEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TimerEventDecoder.cs b/src/Adaptive.Cluster/Codecs/TimerEventDecoder.cs index 10e1d12e..219dd590 100644 --- a/src/Adaptive.Cluster/Codecs/TimerEventDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/TimerEventDecoder.cs @@ -12,7 +12,7 @@ public class TimerEventDecoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 20; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TimerEventDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/TimerEventEncoder.cs b/src/Adaptive.Cluster/Codecs/TimerEventEncoder.cs index e68e0bcb..0072d7cb 100644 --- a/src/Adaptive.Cluster/Codecs/TimerEventEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/TimerEventEncoder.cs @@ -12,7 +12,7 @@ public class TimerEventEncoder public const ushort BLOCK_LENGTH = 24; public const ushort TEMPLATE_ID = 20; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private TimerEventEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/VoteDecoder.cs b/src/Adaptive.Cluster/Codecs/VoteDecoder.cs index daba26c1..af33b484 100644 --- a/src/Adaptive.Cluster/Codecs/VoteDecoder.cs +++ b/src/Adaptive.Cluster/Codecs/VoteDecoder.cs @@ -12,7 +12,7 @@ public class VoteDecoder public const ushort BLOCK_LENGTH = 36; public const ushort TEMPLATE_ID = 52; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private VoteDecoder _parentMessage; private IDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Codecs/VoteEncoder.cs b/src/Adaptive.Cluster/Codecs/VoteEncoder.cs index bee7e9b9..1b76cb19 100644 --- a/src/Adaptive.Cluster/Codecs/VoteEncoder.cs +++ b/src/Adaptive.Cluster/Codecs/VoteEncoder.cs @@ -12,7 +12,7 @@ public class VoteEncoder public const ushort BLOCK_LENGTH = 36; public const ushort TEMPLATE_ID = 52; public const ushort SCHEMA_ID = 111; - public const ushort SCHEMA_VERSION = 14; + public const ushort SCHEMA_VERSION = 16; private VoteEncoder _parentMessage; private IMutableDirectBuffer _buffer; diff --git a/src/Adaptive.Cluster/Service/ClusterMarkFile.cs b/src/Adaptive.Cluster/Service/ClusterMarkFile.cs index 88de741b..fd5258fe 100644 --- a/src/Adaptive.Cluster/Service/ClusterMarkFile.cs +++ b/src/Adaptive.Cluster/Service/ClusterMarkFile.cs @@ -16,6 +16,7 @@ using System; using System.Diagnostics; +using System.Globalization; using System.IO; using Adaptive.Aeron.LogBuffer; using Adaptive.Agrona; @@ -515,9 +516,10 @@ TextWriter logger if (observations > 0) { + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss-fff", CultureInfo.InvariantCulture); var errorLogFilename = Path.Combine( markFile.DirectoryName, - type + "-" + DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss-fff") + "-error.log" + type + "-" + timestamp + "-error.log" ); logger?.WriteLine("WARNING: existing errors saved to: " + errorLogFilename); diff --git a/src/Adaptive.Cluster/Service/ClusteredServiceContainer.cs b/src/Adaptive.Cluster/Service/ClusteredServiceContainer.cs index a9ac2660..6060e126 100644 --- a/src/Adaptive.Cluster/Service/ClusteredServiceContainer.cs +++ b/src/Adaptive.Cluster/Service/ClusteredServiceContainer.cs @@ -553,7 +553,10 @@ public static bool IsRespondingService() return RESPONDER_SERVICE_DEFAULT; } - return "true".Equals(property); + // Match Java Boolean.parseBoolean — case-insensitive. Without TryParse, a user + // setting aeron.cluster.responder.service=True (capital T) would silently be + // treated as false despite the documented default being true. + return bool.TryParse(property, out var b) && b; } /// diff --git a/src/Adaptive.Cluster/aeron-cluster-codecs.xml b/src/Adaptive.Cluster/aeron-cluster-codecs.xml index 3a285736..648c9096 100644 --- a/src/Adaptive.Cluster/aeron-cluster-codecs.xml +++ b/src/Adaptive.Cluster/aeron-cluster-codecs.xml @@ -2,7 +2,7 @@ @@ -363,6 +363,7 @@ + @@ -481,11 +482,12 @@ - + + @@ -595,6 +597,7 @@ + diff --git a/tools/aeron-test-loss-generators/.gitignore b/tools/aeron-test-loss-generators/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/tools/aeron-test-loss-generators/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/aeron-test-loss-generators/build.sh b/tools/aeron-test-loss-generators/build.sh new file mode 100755 index 00000000..e093b967 --- /dev/null +++ b/tools/aeron-test-loss-generators/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Builds aeron-test-loss-generators.jar from sources in src/main/java, using the +# bundled aeron-all jar in ../../driver/media-driver.jar as the compile classpath. +# Output: target/aeron-test-loss-generators.jar and a copy at ../../driver/. +# +# Re-run after editing any source file. The driver/ copy is what EmbeddedMediaDriver +# loads at test time. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +src="$here/src/main/java" +out="$here/target/classes" +jar_out="$here/target/aeron-test-loss-generators.jar" +deployed="$here/../../driver/aeron-test-loss-generators.jar" +driver_jar="$here/../../driver/media-driver.jar" + +if [[ ! -f "$driver_jar" ]]; then + echo "ERROR: $driver_jar not found — driver/media-driver.jar must exist" >&2 + exit 1 +fi + +rm -rf "$out" +mkdir -p "$out" + +find "$src" -name '*.java' -print0 | xargs -0 javac \ + -source 17 -target 17 -encoding UTF-8 \ + -classpath "$driver_jar" \ + -d "$out" + +(cd "$out" && jar -cf "$jar_out" .) +cp "$jar_out" "$deployed" + +echo "Built: $jar_out" +echo "Deployed: $deployed" diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/CompositeLossGenerator.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/CompositeLossGenerator.java new file mode 100644 index 00000000..51b67a37 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/CompositeLossGenerator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Combines several LossGenerator instances so the driver's debug channel endpoints + * (which accept exactly one LossGenerator per direction) can consult all registered + * generators in OR fashion. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.ext.LossGenerator; +import org.agrona.concurrent.UnsafeBuffer; + +import java.net.InetSocketAddress; + +public final class CompositeLossGenerator implements LossGenerator +{ + private final LossGenerator[] generators; + + public CompositeLossGenerator(final LossGenerator... generators) + { + this.generators = generators; + } + + public CompositeLossGenerator(final String tag, final LossGenerator... generators) + { + // tag is descriptive only; kept for future logging without changing the call sites. + this.generators = generators; + } + + public boolean shouldDropFrame(final InetSocketAddress address, final UnsafeBuffer buffer, final int length) + { + for (final LossGenerator g : generators) + { + if (g.shouldDropFrame(address, buffer, length)) + { + return true; + } + } + return false; + } + + public boolean shouldDropFrame( + final InetSocketAddress address, + final UnsafeBuffer buffer, + final int streamId, + final int sessionId, + final int termId, + final int termOffset, + final int length) + { + for (final LossGenerator g : generators) + { + if (g.shouldDropFrame(address, buffer, streamId, sessionId, termId, termOffset, length)) + { + return true; + } + } + return false; + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/DataInRangeLossGenerator.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/DataInRangeLossGenerator.java new file mode 100644 index 00000000..0e002b31 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/DataInRangeLossGenerator.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Vendored from aeron-test-support for use in Aeron.NET integration tests. + * + * Drops incoming DATA frames whose (streamId, termId) matches and whose + * termOffset falls in [termOffsetInclusiveMin, termOffsetExclusiveMax). + * SETUP frames and DATA heartbeats (zero-length, carry EOS/REVOKED flags) always pass. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.ext.LossGenerator; +import io.aeron.protocol.DataHeaderFlyweight; +import io.aeron.protocol.HeaderFlyweight; +import org.agrona.concurrent.UnsafeBuffer; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.net.InetSocketAddress; +import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicInteger; + +public final class DataInRangeLossGenerator implements LossGenerator +{ + private static final VarHandle ENABLED_VH; + + static + { + try + { + ENABLED_VH = MethodHandles.lookup() + .findVarHandle(DataInRangeLossGenerator.class, "enabled", boolean.class); + } + catch (final NoSuchFieldException | IllegalAccessException e) + { + throw new Error(e); + } + } + + private int streamId; + private int activeTermId; + private int termOffsetInclusiveMin; + private int termOffsetExclusiveMax; + private volatile boolean enabled; + private final AtomicInteger framesDropped = new AtomicInteger(); + + public void setTarget( + final int streamId, + final int activeTermId, + final int termOffsetInclusiveMin, + final int termOffsetExclusiveMax) + { + this.streamId = streamId; + this.activeTermId = activeTermId; + this.termOffsetInclusiveMin = termOffsetInclusiveMin; + this.termOffsetExclusiveMax = termOffsetExclusiveMax; + } + + public void enable() + { + ENABLED_VH.setRelease(this, true); + } + + public void disable() + { + ENABLED_VH.setRelease(this, false); + } + + public int framesDropped() + { + return framesDropped.get(); + } + + public boolean shouldDropFrame( + final InetSocketAddress address, + final UnsafeBuffer buffer, + final int streamId, + final int sessionId, + final int termId, + final int termOffset, + final int length) + { + if (!(boolean)ENABLED_VH.getAcquire(this)) + { + return false; + } + + if (length == DataHeaderFlyweight.HEADER_LENGTH && + 0 == buffer.getInt(HeaderFlyweight.FRAME_LENGTH_FIELD_OFFSET, ByteOrder.LITTLE_ENDIAN)) + { + return false; + } + + if (streamId != this.streamId || + termId != this.activeTermId || + termOffset < this.termOffsetInclusiveMin || + termOffset >= this.termOffsetExclusiveMax) + { + return false; + } + + framesDropped.incrementAndGet(); + return true; + } + + public boolean shouldDropFrame(final InetSocketAddress address, final UnsafeBuffer buffer, final int length) + { + return false; + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/FrameDataLossGenerator.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/FrameDataLossGenerator.java new file mode 100644 index 00000000..11cea7e4 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/FrameDataLossGenerator.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Vendored from aeron-test-support for use in Aeron.NET integration tests. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.ext.LossGenerator; +import org.agrona.concurrent.UnsafeBuffer; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.net.InetSocketAddress; +import java.util.function.Predicate; + +public final class FrameDataLossGenerator implements LossGenerator +{ + private static final VarHandle ENABLED_VH; + + static + { + try + { + ENABLED_VH = MethodHandles.lookup() + .findVarHandle(FrameDataLossGenerator.class, "enabled", boolean.class); + } + catch (final NoSuchFieldException | IllegalAccessException e) + { + throw new Error(e); + } + } + + private volatile boolean enabled; + private Predicate dropPredicate; + + public void enable(final Predicate dropPredicate) + { + this.dropPredicate = dropPredicate; + ENABLED_VH.setRelease(this, true); + } + + public void disable() + { + ENABLED_VH.setRelease(this, false); + } + + + public boolean shouldDropFrame(final InetSocketAddress address, final UnsafeBuffer buffer, final int length) + { + if ((boolean)ENABLED_VH.getAcquire(this)) + { + final byte[] bytes = new byte[length]; + buffer.getBytes(0, bytes); + return dropPredicate.test(bytes); + } + return false; + } + + public boolean shouldDropFrame( + final InetSocketAddress address, + final UnsafeBuffer buffer, + final int streamId, + final int sessionId, + final int termId, + final int termOffset, + final int length) + { + return shouldDropFrame(address, buffer, length); + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenControlAgent.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenControlAgent.java new file mode 100644 index 00000000..a2d87f6c --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenControlAgent.java @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Subscribes to LossGenRegistry.CONTROL_CHANNEL on a dedicated thread and dispatches + * commands to the registered loss generators. Wire format is little-endian binary: + * + * byte 0: command opcode (see CMD_* constants) + * bytes 1+: command-specific arguments + * + * Predicates are not serialised; they are referenced by enum (PRED_*) and reconstructed + * server-side. The Predicate for FrameData / StreamIdFrameData generators is + * encoded as a small constant followed by the predicate's args. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.Aeron; +import io.aeron.FragmentAssembler; +import io.aeron.Subscription; +import io.aeron.logbuffer.FragmentHandler; +import org.agrona.DirectBuffer; + +import java.nio.ByteOrder; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; + +public final class LossGenControlAgent implements Runnable +{ + public static final byte CMD_FRAME_DATA_ENABLE = 0x01; + public static final byte CMD_FRAME_DATA_DISABLE = 0x02; + public static final byte CMD_STREAM_ID_ENABLE = 0x03; + public static final byte CMD_STREAM_ID_DISABLE = 0x04; + public static final byte CMD_STREAM_ID_FRAME_DATA_ENABLE = 0x05; + public static final byte CMD_STREAM_ID_FRAME_DATA_DISABLE = 0x06; + public static final byte CMD_DATA_IN_RANGE_SET_TARGET = 0x07; + public static final byte CMD_DATA_IN_RANGE_ENABLE = 0x08; + public static final byte CMD_DATA_IN_RANGE_DISABLE = 0x09; + public static final byte CMD_SETUP_AT_POSITION_SET_TARGET = 0x0A; + public static final byte CMD_SETUP_AT_POSITION_ENABLE = 0x0B; + public static final byte CMD_SETUP_AT_POSITION_DISABLE = 0x0C; + + public static final byte PRED_ALWAYS_TRUE = 0x00; + public static final byte PRED_RANDOM_FRACTION = 0x01; + // Drops everything starting from the first frame whose payload (after the DATA header) + // matches a target byte sequence — used to test that PS notices a half-fragmented message + // when the second half is dropped and falls back to replay. + public static final byte PRED_PAYLOAD_EQUALS_STICKY = 0x02; + + private final Aeron aeron; + private final AtomicBoolean running = new AtomicBoolean(true); + private final FragmentHandler handler; + + public LossGenControlAgent(final Aeron aeron) + { + this.aeron = aeron; + this.handler = new FragmentAssembler(this::onMessage); + } + + public void stop() + { + running.set(false); + } + + public void run() + { + try (Subscription subscription = aeron.addSubscription( + LossGenRegistry.CONTROL_CHANNEL, LossGenRegistry.CONTROL_STREAM_ID)) + { + while (running.get()) + { + if (subscription.poll(handler, 10) == 0) + { + try + { + Thread.sleep(1); + } + catch (final InterruptedException e) + { + Thread.currentThread().interrupt(); + return; + } + } + } + } + } + + private void onMessage(final DirectBuffer buffer, final int offset, final int length, final Object header) + { + if (length < 1) + { + return; + } + final byte cmd = buffer.getByte(offset); + switch (cmd) + { + case CMD_FRAME_DATA_ENABLE: + LossGenRegistry.frameData().enable(decodePredicate(buffer, offset + 1)); + break; + case CMD_FRAME_DATA_DISABLE: + LossGenRegistry.frameData().disable(); + break; + case CMD_STREAM_ID_ENABLE: + LossGenRegistry.streamId().enable(buffer.getInt(offset + 1, ByteOrder.LITTLE_ENDIAN)); + break; + case CMD_STREAM_ID_DISABLE: + LossGenRegistry.streamId().disable(); + break; + case CMD_STREAM_ID_FRAME_DATA_ENABLE: + LossGenRegistry.streamIdFrameData().enable( + buffer.getInt(offset + 1, ByteOrder.LITTLE_ENDIAN), + decodePredicate(buffer, offset + 5)); + break; + case CMD_STREAM_ID_FRAME_DATA_DISABLE: + LossGenRegistry.streamIdFrameData().disable(); + break; + case CMD_DATA_IN_RANGE_SET_TARGET: + LossGenRegistry.dataInRange().setTarget( + buffer.getInt(offset + 1, ByteOrder.LITTLE_ENDIAN), + buffer.getInt(offset + 5, ByteOrder.LITTLE_ENDIAN), + buffer.getInt(offset + 9, ByteOrder.LITTLE_ENDIAN), + buffer.getInt(offset + 13, ByteOrder.LITTLE_ENDIAN)); + break; + case CMD_DATA_IN_RANGE_ENABLE: + LossGenRegistry.dataInRange().enable(); + break; + case CMD_DATA_IN_RANGE_DISABLE: + LossGenRegistry.dataInRange().disable(); + break; + case CMD_SETUP_AT_POSITION_SET_TARGET: + LossGenRegistry.setupAtPosition().setTarget( + buffer.getInt(offset + 1, ByteOrder.LITTLE_ENDIAN), + buffer.getInt(offset + 5, ByteOrder.LITTLE_ENDIAN), + buffer.getInt(offset + 9, ByteOrder.LITTLE_ENDIAN), + buffer.getInt(offset + 13, ByteOrder.LITTLE_ENDIAN)); + break; + case CMD_SETUP_AT_POSITION_ENABLE: + LossGenRegistry.setupAtPosition().enable(); + break; + case CMD_SETUP_AT_POSITION_DISABLE: + LossGenRegistry.setupAtPosition().disable(); + break; + default: + System.err.println("LossGenControlAgent: unknown cmd " + cmd); + break; + } + } + + private static Predicate decodePredicate(final DirectBuffer buffer, final int offset) + { + final byte predType = buffer.getByte(offset); + switch (predType) + { + case PRED_ALWAYS_TRUE: + return bytes -> true; + case PRED_RANDOM_FRACTION: + { + final double fraction = buffer.getDouble(offset + 1, ByteOrder.LITTLE_ENDIAN); + return bytes -> ThreadLocalRandom.current().nextDouble() < fraction; + } + case PRED_PAYLOAD_EQUALS_STICKY: + { + final int matchLen = buffer.getInt(offset + 1, ByteOrder.LITTLE_ENDIAN); + final byte[] match = new byte[matchLen]; + buffer.getBytes(offset + 5, match); + final java.util.concurrent.atomic.AtomicBoolean sticky = + new java.util.concurrent.atomic.AtomicBoolean(false); + return bytes -> + { + if (sticky.get()) + { + return true; + } + final int headerLen = io.aeron.protocol.DataHeaderFlyweight.HEADER_LENGTH; + if (bytes.length - headerLen != matchLen) + { + return false; + } + for (int i = 0; i < matchLen; i++) + { + if (bytes[headerLen + i] != match[i]) + { + return false; + } + } + sticky.set(true); + return true; + }; + } + default: + throw new IllegalArgumentException("unknown predicate type " + predType); + } + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenMediaDriver.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenMediaDriver.java new file mode 100644 index 00000000..d0227d29 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenMediaDriver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Entry point that launches the Aeron media driver and a LossGenControlAgent in the + * same JVM. The control agent connects an Aeron client to the just-started driver + * and processes commands published from the .NET test process on + * LossGenRegistry.CONTROL_CHANNEL. + * + * Drop-in replacement for `io.aeron.driver.MediaDriver` as a process entry point. + * The send/receive channel endpoint suppliers in this jar must already be installed + * via system properties (`aeron.driver.send.channel.endpoint.supplier=...` and + * `aeron.driver.receive.channel.endpoint.supplier=...`). + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.Aeron; +import io.aeron.driver.MediaDriver; +import org.agrona.concurrent.ShutdownSignalBarrier; + +public final class LossGenMediaDriver +{ + public static void main(final String[] args) throws Exception + { + try (MediaDriver driver = MediaDriver.launch(); + Aeron aeron = Aeron.connect(new Aeron.Context() + .aeronDirectoryName(driver.aeronDirectoryName()))) + { + final LossGenControlAgent agent = new LossGenControlAgent(aeron); + final Thread agentThread = new Thread(agent, "loss-gen-control-agent"); + agentThread.setDaemon(true); + agentThread.start(); + + new ShutdownSignalBarrier().await(); + agent.stop(); + } + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenReceiveChannelEndpointSupplier.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenReceiveChannelEndpointSupplier.java new file mode 100644 index 00000000..b3f4c63d --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenReceiveChannelEndpointSupplier.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Wraps every incoming channel endpoint in DebugReceiveChannelEndpoint backed by the + * shared LossGenRegistry singletons. Acts as a no-op when no generator is enabled. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.MediaDriver; +import io.aeron.driver.ReceiveChannelEndpointSupplier; +import io.aeron.driver.DataPacketDispatcher; +import io.aeron.driver.ext.DebugReceiveChannelEndpoint; +import io.aeron.driver.ext.LossGenerator; +import io.aeron.driver.media.ReceiveChannelEndpoint; +import io.aeron.driver.media.UdpChannel; +import org.agrona.concurrent.status.AtomicCounter; + +public final class LossGenReceiveChannelEndpointSupplier implements ReceiveChannelEndpointSupplier +{ + public ReceiveChannelEndpoint newInstance( + final UdpChannel udpChannel, + final DataPacketDispatcher dispatcher, + final AtomicCounter statusIndicator, + final MediaDriver.Context context) + { + // DebugReceiveChannelEndpoint routes incoming SETUPs (and RTT responses) through the + // *data* loss generator — not the control one, despite the names. Control here is the + // OUTGOING NAK/RTT path. So SetupAtPosition must live with the inbound DATA filters. + final LossGenerator dataLossGenerator = new CompositeLossGenerator( + "recv-data:" + udpChannel.originalUriString(), + LossGenRegistry.frameData(), + LossGenRegistry.streamIdFrameData(), + LossGenRegistry.streamId(), + LossGenRegistry.dataInRange(), + LossGenRegistry.setupAtPosition()); + final LossGenerator controlLossGenerator = new CompositeLossGenerator( + "send-ctrl:" + udpChannel.originalUriString(), + LossGenRegistry.frameData()); + return new DebugReceiveChannelEndpoint( + udpChannel, dispatcher, statusIndicator, context, dataLossGenerator, controlLossGenerator); + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenRegistry.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenRegistry.java new file mode 100644 index 00000000..9e40a041 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenRegistry.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Singleton registry of loss generator instances installed in this JVM. The + * driver's debug channel endpoints pull the singletons from here at construction; + * the LossGenControlAgent updates their state via commands received from the .NET + * test process. + */ +package io.adaptive.aeron.test.lossgen; + +public final class LossGenRegistry +{ + public static final int CONTROL_STREAM_ID = 10001; + public static final String CONTROL_CHANNEL = "aeron:ipc?session-id=99999"; + + private static final FrameDataLossGenerator FRAME_DATA = new FrameDataLossGenerator(); + private static final StreamIdLossGenerator STREAM_ID = new StreamIdLossGenerator(); + private static final StreamIdFrameDataLossGenerator STREAM_ID_FRAME_DATA = new StreamIdFrameDataLossGenerator(); + private static final DataInRangeLossGenerator DATA_IN_RANGE = new DataInRangeLossGenerator(); + private static final SetupAtPositionLossGenerator SETUP_AT_POSITION = new SetupAtPositionLossGenerator(); + + private LossGenRegistry() + { + } + + public static FrameDataLossGenerator frameData() + { + return FRAME_DATA; + } + + public static StreamIdLossGenerator streamId() + { + return STREAM_ID; + } + + public static StreamIdFrameDataLossGenerator streamIdFrameData() + { + return STREAM_ID_FRAME_DATA; + } + + public static DataInRangeLossGenerator dataInRange() + { + return DATA_IN_RANGE; + } + + public static SetupAtPositionLossGenerator setupAtPosition() + { + return SETUP_AT_POSITION; + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenSendChannelEndpointSupplier.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenSendChannelEndpointSupplier.java new file mode 100644 index 00000000..670bb311 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/LossGenSendChannelEndpointSupplier.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Wraps every outgoing channel endpoint in DebugSendChannelEndpoint backed by the + * shared LossGenRegistry singletons. Acts as a no-op when no generator is enabled. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.MediaDriver; +import io.aeron.driver.SendChannelEndpointSupplier; +import io.aeron.driver.ext.DebugSendChannelEndpoint; +import io.aeron.driver.ext.LossGenerator; +import io.aeron.driver.media.SendChannelEndpoint; +import io.aeron.driver.media.UdpChannel; +import org.agrona.concurrent.status.AtomicCounter; + +public final class LossGenSendChannelEndpointSupplier implements SendChannelEndpointSupplier +{ + public SendChannelEndpoint newInstance( + final UdpChannel udpChannel, + final AtomicCounter statusIndicator, + final MediaDriver.Context context) + { + final LossGenerator dataLossGenerator = new CompositeLossGenerator( + "send-data:" + udpChannel.originalUriString(), + LossGenRegistry.frameData(), + LossGenRegistry.streamIdFrameData(), + LossGenRegistry.streamId(), + LossGenRegistry.dataInRange()); + final LossGenerator controlLossGenerator = new CompositeLossGenerator( + "send-ctrl:" + udpChannel.originalUriString(), + LossGenRegistry.frameData(), + LossGenRegistry.setupAtPosition()); + return new DebugSendChannelEndpoint( + udpChannel, statusIndicator, context, dataLossGenerator, controlLossGenerator); + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/SetupAtPositionLossGenerator.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/SetupAtPositionLossGenerator.java new file mode 100644 index 00000000..df9613fd --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/SetupAtPositionLossGenerator.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Vendored from aeron-test-support for use in Aeron.NET integration tests. + * + * Drops incoming SETUP frames whose (streamId, initialTermId, activeTermId, termOffset) + * tuple matches a target. DATA frames always pass. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.ext.LossGenerator; +import io.aeron.protocol.HeaderFlyweight; +import io.aeron.protocol.SetupFlyweight; +import org.agrona.concurrent.UnsafeBuffer; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.net.InetSocketAddress; +import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicInteger; + +public final class SetupAtPositionLossGenerator implements LossGenerator +{ + private static final VarHandle ENABLED_VH; + + static + { + try + { + ENABLED_VH = MethodHandles.lookup() + .findVarHandle(SetupAtPositionLossGenerator.class, "enabled", boolean.class); + } + catch (final NoSuchFieldException | IllegalAccessException e) + { + throw new Error(e); + } + } + + private final SetupFlyweight setupFlyweight = new SetupFlyweight(); + private int streamId; + private int initialTermId; + private int activeTermId; + private int termOffset; + private volatile boolean enabled; + private final AtomicInteger setupsDropped = new AtomicInteger(); + + public void setTarget( + final int streamId, + final int initialTermId, + final int activeTermId, + final int termOffset) + { + this.streamId = streamId; + this.initialTermId = initialTermId; + this.activeTermId = activeTermId; + this.termOffset = termOffset; + } + + public void enable() + { + ENABLED_VH.setRelease(this, true); + } + + public void disable() + { + ENABLED_VH.setRelease(this, false); + } + + public int setupsDropped() + { + return setupsDropped.get(); + } + + public boolean shouldDropFrame(final InetSocketAddress address, final UnsafeBuffer buffer, final int length) + { + if (!(boolean)ENABLED_VH.getAcquire(this)) + { + return false; + } + + if (length < SetupFlyweight.HEADER_LENGTH) + { + return false; + } + + final int type = buffer.getShort(HeaderFlyweight.TYPE_FIELD_OFFSET, ByteOrder.LITTLE_ENDIAN) & 0xFFFF; + if (HeaderFlyweight.HDR_TYPE_SETUP != type) + { + return false; + } + + setupFlyweight.wrap(buffer, 0, length); + if (setupFlyweight.streamId() != this.streamId || + setupFlyweight.initialTermId() != this.initialTermId || + setupFlyweight.activeTermId() != this.activeTermId || + setupFlyweight.termOffset() != this.termOffset) + { + return false; + } + + setupsDropped.incrementAndGet(); + return true; + } + + public boolean shouldDropFrame( + final InetSocketAddress address, + final UnsafeBuffer buffer, + final int streamId, + final int sessionId, + final int termId, + final int termOffset, + final int length) + { + return false; + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/StreamIdFrameDataLossGenerator.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/StreamIdFrameDataLossGenerator.java new file mode 100644 index 00000000..6c47390b --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/StreamIdFrameDataLossGenerator.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Vendored from aeron-test-support for use in Aeron.NET integration tests. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.ext.LossGenerator; +import org.agrona.concurrent.UnsafeBuffer; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.net.InetSocketAddress; +import java.util.function.Predicate; + +public final class StreamIdFrameDataLossGenerator implements LossGenerator +{ + private static final VarHandle ENABLED_VH; + + static + { + try + { + ENABLED_VH = MethodHandles.lookup() + .findVarHandle(StreamIdFrameDataLossGenerator.class, "enabled", boolean.class); + } + catch (final NoSuchFieldException | IllegalAccessException e) + { + throw new Error(e); + } + } + + private int streamId; + private volatile boolean enabled; + private Predicate dropPredicate; + + public void enable(final int streamId, final Predicate dropPredicate) + { + this.dropPredicate = dropPredicate; + this.streamId = streamId; + ENABLED_VH.setRelease(this, true); + } + + public void disable() + { + ENABLED_VH.setRelease(this, false); + } + + public boolean shouldDropFrame( + final InetSocketAddress address, + final UnsafeBuffer buffer, + final int streamId, + final int sessionId, + final int termId, + final int termOffset, + final int length) + { + if ((boolean)ENABLED_VH.getAcquire(this) && streamId == this.streamId) + { + final byte[] bytes = new byte[length]; + buffer.getBytes(0, bytes); + return dropPredicate.test(bytes); + } + return false; + } + + public boolean shouldDropFrame(final InetSocketAddress address, final UnsafeBuffer buffer, final int length) + { + return false; + } +} diff --git a/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/StreamIdLossGenerator.java b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/StreamIdLossGenerator.java new file mode 100644 index 00000000..f30830a5 --- /dev/null +++ b/tools/aeron-test-loss-generators/src/main/java/io/adaptive/aeron/test/lossgen/StreamIdLossGenerator.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Adaptive Financial Consulting Limited. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Vendored from aeron-test-support for use in Aeron.NET integration tests. + */ +package io.adaptive.aeron.test.lossgen; + +import io.aeron.driver.ext.LossGenerator; +import org.agrona.concurrent.UnsafeBuffer; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.net.InetSocketAddress; + +public final class StreamIdLossGenerator implements LossGenerator +{ + private static final VarHandle ENABLED_VH; + + static + { + try + { + ENABLED_VH = MethodHandles.lookup().findVarHandle(StreamIdLossGenerator.class, "enabled", boolean.class); + } + catch (final NoSuchFieldException | IllegalAccessException e) + { + throw new Error(e); + } + } + + private int streamId; + private volatile boolean enabled; + + public void enable(final int streamId) + { + this.streamId = streamId; + ENABLED_VH.setRelease(this, true); + } + + public void disable() + { + ENABLED_VH.setRelease(this, false); + } + + public boolean shouldDropFrame( + final InetSocketAddress address, + final UnsafeBuffer buffer, + final int streamId, + final int sessionId, + final int termId, + final int termOffset, + final int length) + { + return (boolean)ENABLED_VH.getAcquire(this) && streamId == this.streamId; + } + + public boolean shouldDropFrame(final InetSocketAddress address, final UnsafeBuffer buffer, final int length) + { + return false; + } +}