diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index e121c28bf..c57a9ce3d 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - framework: [ net8.0 ] + framework: [ net9.0 ] os: [ ubuntu-latest ] configuration: [ release ] runs-on: ${{ matrix.os }} @@ -53,7 +53,7 @@ jobs: uses: actions/setup-dotnet@v3 with: dotnet-version: | - 8.0.x + 9.0.x - name: Run Tests shell: bash env: @@ -64,7 +64,7 @@ jobs: dotnet test --configuration ${{ matrix.configuration }} --blame \ --logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \ --framework ${{ matrix.framework }} \ - test/EventStore.Client.Tests + test/Kurrent.Client.Tests # run: | # sudo ./gencert.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1ab57e76..1f408ae3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,8 @@ jobs: strategy: fail-fast: false matrix: - docker-tag: [ ci, lts, previous-lts ] +# docker-tag: [ ci, lts, previous-lts ] + docker-tag: [ ci ] # test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement ] name: Test CE (${{ matrix.docker-tag }}) with: diff --git a/EventStore.Client.sln b/Kurrent.Client.sln similarity index 66% rename from EventStore.Client.sln rename to Kurrent.Client.sln index 4b4791ec9..63cdbc278 100644 --- a/EventStore.Client.sln +++ b/Kurrent.Client.sln @@ -9,12 +9,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client", "src\Ev EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C51F2C69-45A9-4D0D-A708-4FC319D5D340}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Tests", "test\EventStore.Client.Tests\EventStore.Client.Tests.csproj", "{FC829F1B-43AD-4C96-9002-23D04BBA3AF3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Tests.Common", "test\EventStore.Client.Tests.Common\EventStore.Client.Tests.Common.csproj", "{E326832D-DE52-4DE4-9E54-C800908B75F3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kurrent.Client.Tests", "test\Kurrent.Client.Tests\Kurrent.Client.Tests.csproj", "{FC829F1B-43AD-4C96-9002-23D04BBA3AF3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Extensions.OpenTelemetry", "src\EventStore.Client.Extensions.OpenTelemetry\EventStore.Client.Extensions.OpenTelemetry.csproj", "{F6A7B391-36F1-4838-AD08-E0EE0F2FE57E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kurrent.Client", "src\Kurrent.Client\Kurrent.Client.csproj", "{762EECAA-122E-4B0C-BC50-5AA4F72CA4E0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kurrent.Client.Tests.Common", "test\Kurrent.Client.Tests.Common\Kurrent.Client.Tests.Common.csproj", "{47BF715B-A0BF-4044-B335-717E56422550}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -32,19 +34,24 @@ Global {FC829F1B-43AD-4C96-9002-23D04BBA3AF3}.Debug|x64.Build.0 = Debug|Any CPU {FC829F1B-43AD-4C96-9002-23D04BBA3AF3}.Release|x64.ActiveCfg = Release|Any CPU {FC829F1B-43AD-4C96-9002-23D04BBA3AF3}.Release|x64.Build.0 = Release|Any CPU - {E326832D-DE52-4DE4-9E54-C800908B75F3}.Debug|x64.ActiveCfg = Debug|Any CPU - {E326832D-DE52-4DE4-9E54-C800908B75F3}.Debug|x64.Build.0 = Debug|Any CPU - {E326832D-DE52-4DE4-9E54-C800908B75F3}.Release|x64.ActiveCfg = Release|Any CPU - {E326832D-DE52-4DE4-9E54-C800908B75F3}.Release|x64.Build.0 = Release|Any CPU {F6A7B391-36F1-4838-AD08-E0EE0F2FE57E}.Debug|x64.ActiveCfg = Debug|Any CPU {F6A7B391-36F1-4838-AD08-E0EE0F2FE57E}.Debug|x64.Build.0 = Debug|Any CPU {F6A7B391-36F1-4838-AD08-E0EE0F2FE57E}.Release|x64.ActiveCfg = Release|Any CPU {F6A7B391-36F1-4838-AD08-E0EE0F2FE57E}.Release|x64.Build.0 = Release|Any CPU + {762EECAA-122E-4B0C-BC50-5AA4F72CA4E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {762EECAA-122E-4B0C-BC50-5AA4F72CA4E0}.Debug|x64.Build.0 = Debug|Any CPU + {762EECAA-122E-4B0C-BC50-5AA4F72CA4E0}.Release|x64.ActiveCfg = Release|Any CPU + {762EECAA-122E-4B0C-BC50-5AA4F72CA4E0}.Release|x64.Build.0 = Release|Any CPU + {47BF715B-A0BF-4044-B335-717E56422550}.Debug|x64.ActiveCfg = Debug|Any CPU + {47BF715B-A0BF-4044-B335-717E56422550}.Debug|x64.Build.0 = Debug|Any CPU + {47BF715B-A0BF-4044-B335-717E56422550}.Release|x64.ActiveCfg = Release|Any CPU + {47BF715B-A0BF-4044-B335-717E56422550}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8853D875-4A8E-450B-A1BE-9CEF8BEDABC3} = {EA59C1CB-16DA-4F68-AF8A-642A969B4CF8} {FC829F1B-43AD-4C96-9002-23D04BBA3AF3} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} - {E326832D-DE52-4DE4-9E54-C800908B75F3} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} {F6A7B391-36F1-4838-AD08-E0EE0F2FE57E} = {EA59C1CB-16DA-4F68-AF8A-642A969B4CF8} + {762EECAA-122E-4B0C-BC50-5AA4F72CA4E0} = {EA59C1CB-16DA-4F68-AF8A-642A969B4CF8} + {47BF715B-A0BF-4044-B335-717E56422550} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} EndGlobalSection EndGlobal diff --git a/EventStore.Client.sln.DotSettings b/Kurrent.Client.sln.DotSettings similarity index 100% rename from EventStore.Client.sln.DotSettings rename to Kurrent.Client.sln.DotSettings diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9f5159807..38e546298 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,8 +3,8 @@ true - EventStore.Client - EventStore.Client + Kurrent.Client + Kurrent.Client @@ -12,8 +12,8 @@ LICENSE.md https://kurrent.io false - https://eventstore.com/blog/ - kurrent eventstore client grpc + https://kurrent.io/blog/ + kurrent client grpc Kurrent Ltd Copyright 2012-$([System.DateTime]::Today.Year.ToString()) Kurrent Ltd v diff --git a/src/Kurrent.Client/Core/Certificates/X509Certificates.cs b/src/Kurrent.Client/Core/Certificates/X509Certificates.cs new file mode 100644 index 000000000..3fe1006f5 --- /dev/null +++ b/src/Kurrent.Client/Core/Certificates/X509Certificates.cs @@ -0,0 +1,114 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +#if NET48 +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; +#endif + +namespace EventStore.Client; + +static class X509Certificates { + // TODO SS: Use .NET 8 X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath) once the Windows32Exception issue is resolved + public static X509Certificate2 CreateFromPemFile(string certPemFilePath, string keyPemFilePath) { + try { +#if NET9_0_OR_GREATER + using var publicCert = X509CertificateLoader.LoadCertificateFromFile(certPemFilePath); +#else + using var publicCert = new X509Certificate2(certPemFilePath); +#endif + using var privateKey = RSA.Create().ImportPrivateKeyFromFile(keyPemFilePath); + using var certificate = publicCert.CopyWithPrivateKey(privateKey); + +#if NET48 + return new(certificate.Export(X509ContentType.Pfx)); +#else + return X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath); +#endif + } catch (Exception ex) { + throw new CryptographicException($"Failed to load private key: {ex.Message}"); + } + + // Notes: + // using X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath) would be the ideal choice here, + // but it's currently causing a Win32Exception specifically on Windows. Alternative implementation is used until the issue is resolved. + // + // Error: The SSL connection could not be established, see inner exception. AuthenticationException: Authentication failed because the platform + // does not support ephemeral keys. Win32Exception: No credentials are available in the security package + // + // public static X509Certificate2 CreateFromPemFile(string certPemFilePath, string keyPemFilePath) => + // X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath); + } +} + +public static class RsaExtensions { +#if NET48 + public static RSA ImportPrivateKeyFromFile(this RSA rsa, string privateKeyPath) { + var (content, label) = LoadPemKeyFile(privateKeyPath); + + using var reader = new PemReader(new StringReader(string.Join(Environment.NewLine, content))); + + var keyParameters = reader.ReadObject() switch { + RsaPrivateCrtKeyParameters parameters => parameters, + AsymmetricCipherKeyPair keyPair => keyPair.Private as RsaPrivateCrtKeyParameters, + _ => throw new NotSupportedException($"Invalid private key format: {label}") + }; + + rsa.ImportParameters(DotNetUtilities.ToRSAParameters(keyParameters)); + + return rsa; + } +#else + public static RSA ImportPrivateKeyFromFile(this RSA rsa, string privateKeyPath) { + var (content, label) = LoadPemKeyFile(privateKeyPath); + + var privateKey = string.Join(string.Empty, content[1..^1]); + var privateKeyBytes = Convert.FromBase64String(privateKey); + + if (label == RsaPemLabels.Pkcs8PrivateKey) + rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _); + else if (label == RsaPemLabels.RSAPrivateKey) + rsa.ImportRSAPrivateKey(privateKeyBytes, out _); + + return rsa; + } +#endif + + static (string[] Content, string Label) LoadPemKeyFile(string privateKeyPath) { + var content = File.ReadAllLines(privateKeyPath); + var label = RsaPemLabels.ParseKeyLabel(content[0]); + + if (RsaPemLabels.IsEncryptedPrivateKey(label)) + throw new NotSupportedException("Encrypted private keys are not supported"); + + return (content, label); + } +} + +static class RsaPemLabels { + public const string RSAPrivateKey = "RSA PRIVATE KEY"; + public const string Pkcs8PrivateKey = "PRIVATE KEY"; + public const string EncryptedPkcs8PrivateKey = "ENCRYPTED PRIVATE KEY"; + + public static readonly string[] PrivateKeyLabels = [RSAPrivateKey, Pkcs8PrivateKey, EncryptedPkcs8PrivateKey]; + + public static bool IsPrivateKey(string label) => Array.IndexOf(PrivateKeyLabels, label) != -1; + + public static bool IsEncryptedPrivateKey(string label) => label == EncryptedPkcs8PrivateKey; + + const string LabelPrefix = "-----BEGIN "; + const string LabelSuffix = "-----"; + + public static string ParseKeyLabel(string pemFileHeader) { + var label = pemFileHeader.Replace(LabelPrefix, string.Empty).Replace(LabelSuffix, string.Empty); + + if (!IsPrivateKey(label)) + throw new CryptographicException($"Unknown private key label: {label}"); + + return label; + } +} diff --git a/src/Kurrent.Client/Core/ChannelBaseExtensions.cs b/src/Kurrent.Client/Core/ChannelBaseExtensions.cs new file mode 100644 index 000000000..9c44addef --- /dev/null +++ b/src/Kurrent.Client/Core/ChannelBaseExtensions.cs @@ -0,0 +1,10 @@ +using Grpc.Core; + +namespace EventStore.Client; + +static class ChannelBaseExtensions { + public static async ValueTask DisposeAsync(this ChannelBase channel) { + await channel.ShutdownAsync().ConfigureAwait(false); + (channel as IDisposable)?.Dispose(); + } +} diff --git a/src/Kurrent.Client/Core/ChannelCache.cs b/src/Kurrent.Client/Core/ChannelCache.cs new file mode 100644 index 000000000..09f7c2b86 --- /dev/null +++ b/src/Kurrent.Client/Core/ChannelCache.cs @@ -0,0 +1,146 @@ +using System.Net; +using TChannel = Grpc.Net.Client.GrpcChannel; + +namespace EventStore.Client { + // Maintains Channels keyed by DnsEndPoint so the channels can be reused. + // Deals with the disposal difference between grpc.net and grpc.core + // Thread safe. + internal class ChannelCache : + IAsyncDisposable { + + private readonly KurrentClientSettings _settings; + private readonly Random _random; + private readonly Dictionary _channels; + private readonly object _lock = new(); + private bool _disposed; + + public ChannelCache(KurrentClientSettings settings) { + _settings = settings; + _random = new Random(0); + _channels = new Dictionary( + DnsEndPointEqualityComparer.Instance); + } + + public TChannel GetChannelInfo(DnsEndPoint endPoint) { + lock (_lock) { + ThrowIfDisposed(); + + if (!_channels.TryGetValue(endPoint, out var channel)) { + channel = ChannelFactory.CreateChannel( + settings: _settings, + endPoint: endPoint); + _channels[endPoint] = channel; + } + + return channel; + } + } + + public KeyValuePair[] GetRandomOrderSnapshot() { + lock (_lock) { + ThrowIfDisposed(); + + return _channels + .OrderBy(_ => _random.Next()) + .ToArray(); + } + } + + // Update the cache to contain channels for exactly these endpoints + public void UpdateCache(IEnumerable endPoints) { + lock (_lock) { + ThrowIfDisposed(); + + // remove + var endPointsToDiscard = _channels.Keys + .Except(endPoints, DnsEndPointEqualityComparer.Instance) + .ToArray(); + + var channelsToDispose = new List(endPointsToDiscard.Length); + + foreach (var endPoint in endPointsToDiscard) { + if (!_channels.TryGetValue(endPoint, out var channel)) + continue; + + _channels.Remove(endPoint); + channelsToDispose.Add(channel); + } + + _ = DisposeChannelsAsync(channelsToDispose); + + // add + foreach (var endPoint in endPoints) { + GetChannelInfo(endPoint); + } + } + } + + public void Dispose() { + lock (_lock) { + if (_disposed) + return; + + _disposed = true; + + foreach (var channel in _channels.Values) { + channel.Dispose(); + } + + _channels.Clear(); + } + } + + public async ValueTask DisposeAsync() { + var channelsToDispose = Array.Empty(); + + lock (_lock) { + if (_disposed) + return; + _disposed = true; + + channelsToDispose = _channels.Values.ToArray(); + _channels.Clear(); + } + + await DisposeChannelsAsync(channelsToDispose).ConfigureAwait(false); + } + + private void ThrowIfDisposed() { + lock (_lock) { + if (_disposed) { + throw new ObjectDisposedException(GetType().ToString()); + } + } + } + + private static async Task DisposeChannelsAsync(IEnumerable channels) { + foreach (var channel in channels) + await channel.DisposeAsync().ConfigureAwait(false); + } + + private class DnsEndPointEqualityComparer : IEqualityComparer { + public static readonly DnsEndPointEqualityComparer Instance = new(); + + public bool Equals(DnsEndPoint? x, DnsEndPoint? y) { + if (ReferenceEquals(x, y)) + return true; + if (x is null) + return false; + if (y is null) + return false; + if (x.GetType() != y.GetType()) + return false; + return + string.Equals(x.Host, y.Host, StringComparison.OrdinalIgnoreCase) && + x.Port == y.Port; + } + + public int GetHashCode(DnsEndPoint obj) { + unchecked { + return (StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Host) * 397) ^ + obj.Port; + } + } + } + } +} diff --git a/src/Kurrent.Client/Core/ChannelFactory.cs b/src/Kurrent.Client/Core/ChannelFactory.cs new file mode 100644 index 000000000..c63605bb4 --- /dev/null +++ b/src/Kurrent.Client/Core/ChannelFactory.cs @@ -0,0 +1,106 @@ +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using Grpc.Net.Client; +using EndPoint = System.Net.EndPoint; +using TChannel = Grpc.Net.Client.GrpcChannel; + +namespace EventStore.Client { + + internal static class ChannelFactory { + private const int MaxReceiveMessageLength = 17 * 1024 * 1024; + + public static TChannel CreateChannel(KurrentClientSettings settings, EndPoint endPoint) { + var address = endPoint.ToUri(!settings.ConnectivitySettings.Insecure); + + if (settings.ConnectivitySettings.Insecure) { + //this must be switched on before creation of the HttpMessageHandler + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + } + + return TChannel.ForAddress( + address, + new GrpcChannelOptions { +#if NET48 + HttpHandler = CreateHandler(settings), +#else + HttpClient = new HttpClient(CreateHandler(settings), true) { + Timeout = System.Threading.Timeout.InfiniteTimeSpan, + DefaultRequestVersion = new Version(2, 0) + }, +#endif + LoggerFactory = settings.LoggerFactory, + Credentials = settings.ChannelCredentials, + DisposeHttpClient = true, + MaxReceiveMessageSize = MaxReceiveMessageLength + } + ); + + +#if NET48 + static HttpMessageHandler CreateHandler(KurrentClientSettings settings) { + if (settings.CreateHttpMessageHandler is not null) + return settings.CreateHttpMessageHandler.Invoke(); + + var handler = new WinHttpHandler { + TcpKeepAliveEnabled = true, + TcpKeepAliveTime = settings.ConnectivitySettings.KeepAliveTimeout, + TcpKeepAliveInterval = settings.ConnectivitySettings.KeepAliveInterval, + EnableMultipleHttp2Connections = true + }; + + if (settings.ConnectivitySettings.Insecure) return handler; + + if (settings.ConnectivitySettings.ClientCertificate is not null) + handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); + + handler.ServerCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (chain is null) return false; + + chain.ChainPolicy.ExtraStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(certificate); + }, + _ => null + }; + + return handler; + } +#else + static HttpMessageHandler CreateHandler(KurrentClientSettings settings) { + if (settings.CreateHttpMessageHandler is not null) + return settings.CreateHttpMessageHandler.Invoke(); + + var handler = new SocketsHttpHandler { + KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval, + KeepAlivePingTimeout = settings.ConnectivitySettings.KeepAliveTimeout, + EnableMultipleHttp2Connections = true + }; + + if (settings.ConnectivitySettings.Insecure) + return handler; + + if (settings.ConnectivitySettings.ClientCertificate is not null) { + handler.SslOptions.ClientCertificates = new X509CertificateCollection { + settings.ConnectivitySettings.ClientCertificate + }; + } + + handler.SslOptions.RemoteCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (certificate is not X509Certificate2 peerCertificate || chain is null) return false; + + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(peerCertificate); + }, + _ => null + }; + + return handler; + } +#endif + } + } +} diff --git a/src/Kurrent.Client/Core/ChannelInfo.cs b/src/Kurrent.Client/Core/ChannelInfo.cs new file mode 100644 index 000000000..87c80b644 --- /dev/null +++ b/src/Kurrent.Client/Core/ChannelInfo.cs @@ -0,0 +1,9 @@ +using Grpc.Core; + +namespace EventStore.Client { +#pragma warning disable 1591 + public record ChannelInfo( + ChannelBase Channel, + ServerCapabilities ServerCapabilities, + CallInvoker CallInvoker); +} diff --git a/src/Kurrent.Client/Core/ChannelSelector.cs b/src/Kurrent.Client/Core/ChannelSelector.cs new file mode 100644 index 000000000..af0fa3031 --- /dev/null +++ b/src/Kurrent.Client/Core/ChannelSelector.cs @@ -0,0 +1,24 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; + +namespace EventStore.Client { + internal class ChannelSelector : IChannelSelector { + private readonly IChannelSelector _inner; + + public ChannelSelector( + KurrentClientSettings settings, + ChannelCache channelCache) { + _inner = settings.ConnectivitySettings.IsSingleNode + ? new SingleNodeChannelSelector(settings, channelCache) + : new GossipChannelSelector(settings, channelCache, new GrpcGossipClient(settings)); + } + + public Task SelectChannelAsync(CancellationToken cancellationToken) => + _inner.SelectChannelAsync(cancellationToken); + + public ChannelBase SelectChannel(DnsEndPoint endPoint) => + _inner.SelectChannel(endPoint); + } +} diff --git a/src/Kurrent.Client/Core/ClusterMessage.cs b/src/Kurrent.Client/Core/ClusterMessage.cs new file mode 100644 index 000000000..64701781e --- /dev/null +++ b/src/Kurrent.Client/Core/ClusterMessage.cs @@ -0,0 +1,28 @@ +using System.Net; + +namespace EventStore.Client { + internal static class ClusterMessages { + public record ClusterInfo(MemberInfo[] Members); + + public record MemberInfo(Uuid InstanceId, VNodeState State, bool IsAlive, DnsEndPoint EndPoint); + + public enum VNodeState { + Initializing = 0, + DiscoverLeader = 1, + Unknown = 2, + PreReplica = 3, + CatchingUp = 4, + Clone = 5, + Follower = 6, + PreLeader = 7, + Leader = 8, + Manager = 9, + ShuttingDown = 10, + Shutdown = 11, + ReadOnlyLeaderless = 12, + PreReadOnlyReplica = 13, + ReadOnlyReplica = 14, + ResigningLeader = 15 + } + } +} diff --git a/src/Kurrent.Client/Core/Common/AsyncStreamReaderExtensions.cs b/src/Kurrent.Client/Core/Common/AsyncStreamReaderExtensions.cs new file mode 100644 index 000000000..98f9de54d --- /dev/null +++ b/src/Kurrent.Client/Core/Common/AsyncStreamReaderExtensions.cs @@ -0,0 +1,29 @@ +using System.Threading.Channels; +using System.Runtime.CompilerServices; +using Grpc.Core; + +namespace EventStore.Client; + +static class AsyncStreamReaderExtensions { + public static async IAsyncEnumerable ReadAllAsync( + this IAsyncStreamReader reader, + [EnumeratorCancellation] + CancellationToken cancellationToken = default + ) { + while (await reader.MoveNext(cancellationToken).ConfigureAwait(false)) + yield return reader.Current; + } + + public static async IAsyncEnumerable ReadAllAsync(this ChannelReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET + await foreach (var item in reader.ReadAllAsync(cancellationToken)) + yield return item; +#else + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { + while (reader.TryRead(out T? item)) { + yield return item; + } + } +#endif + } +} diff --git a/src/Kurrent.Client/Core/Common/Constants.cs b/src/Kurrent.Client/Core/Common/Constants.cs new file mode 100644 index 000000000..2ed9d7c82 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Constants.cs @@ -0,0 +1,62 @@ +namespace EventStore.Client; + +static class Constants { + public static class Exceptions { + public const string ExceptionKey = "exception"; + + public const string AccessDenied = "access-denied"; + public const string InvalidTransaction = "invalid-transaction"; + public const string StreamDeleted = "stream-deleted"; + public const string WrongExpectedVersion = "wrong-expected-version"; + public const string StreamNotFound = "stream-not-found"; + public const string MaximumAppendSizeExceeded = "maximum-append-size-exceeded"; + public const string MissingRequiredMetadataProperty = "missing-required-metadata-property"; + public const string NotLeader = "not-leader"; + + public const string PersistentSubscriptionFailed = "persistent-subscription-failed"; + public const string PersistentSubscriptionDoesNotExist = "persistent-subscription-does-not-exist"; + public const string PersistentSubscriptionExists = "persistent-subscription-exists"; + public const string MaximumSubscribersReached = "maximum-subscribers-reached"; + public const string PersistentSubscriptionDropped = "persistent-subscription-dropped"; + + public const string UserNotFound = "user-not-found"; + public const string UserConflict = "user-conflict"; + + public const string ScavengeNotFound = "scavenge-not-found"; + + public const string ExpectedVersion = "expected-version"; + public const string ActualVersion = "actual-version"; + public const string StreamName = "stream-name"; + public const string GroupName = "group-name"; + public const string Reason = "reason"; + public const string MaximumAppendSize = "maximum-append-size"; + public const string RequiredMetadataProperties = "required-metadata-properties"; + public const string ScavengeId = "scavenge-id"; + public const string LeaderEndpointHost = "leader-endpoint-host"; + public const string LeaderEndpointPort = "leader-endpoint-port"; + + public const string LoginName = "login-name"; + } + + public static class Metadata { + public const string Type = "type"; + public const string Created = "created"; + public const string ContentType = "content-type"; + + public static readonly string[] RequiredMetadata = [Type, ContentType]; + + public static class ContentTypes { + public const string ApplicationJson = "application/json"; + public const string ApplicationOctetStream = "application/octet-stream"; + } + } + + public static class Headers { + public const string Authorization = "authorization"; + public const string BasicScheme = "Basic"; + public const string BearerScheme = "Bearer"; + + public const string ConnectionName = "connection-name"; + public const string RequiresLeader = "requires-leader"; + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs b/src/Kurrent.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs new file mode 100644 index 000000000..ab914d8c9 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs @@ -0,0 +1,85 @@ +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + +using System.Diagnostics; +using Kurrent.Diagnostics; +using Kurrent.Diagnostics.Telemetry; +using Kurrent.Diagnostics.Tracing; + +namespace EventStore.Client.Diagnostics; + +static class ActivitySourceExtensions { + public static async ValueTask TraceClientOperation( + this ActivitySource source, + Func> tracedOperation, + string operationName, + ActivityTagsCollection? tags = null + ) { + using var activity = StartActivity(source, operationName, ActivityKind.Client, tags, Activity.Current?.Context); + + try { + var res = await tracedOperation().ConfigureAwait(false); + activity?.StatusOk(); + return res; + } catch (Exception ex) { + activity?.StatusError(ex); + throw; + } + } + + public static void TraceSubscriptionEvent( + this ActivitySource source, + string? subscriptionId, + ResolvedEvent resolvedEvent, + ChannelInfo channelInfo, + KurrentClientSettings settings, + UserCredentials? userCredentials + ) { + if (source.HasNoActiveListeners() || resolvedEvent.Event is null) + return; + + var parentContext = resolvedEvent.Event.Metadata.ExtractPropagationContext(); + + if (parentContext == default(ActivityContext)) return; + + var tags = new ActivityTagsCollection() + .WithRequiredTag(TelemetryTags.Kurrent.Stream, resolvedEvent.OriginalEvent.EventStreamId) + .WithOptionalTag(TelemetryTags.Kurrent.SubscriptionId, subscriptionId) + .WithRequiredTag(TelemetryTags.Kurrent.EventId, resolvedEvent.OriginalEvent.EventId.ToString()) + .WithRequiredTag(TelemetryTags.Kurrent.EventType, resolvedEvent.OriginalEvent.EventType) + // Ensure consistent server.address attribute when connecting to cluster via dns discovery + .WithGrpcChannelServerTags(channelInfo) + .WithClientSettingsServerTags(settings) + .WithOptionalTag( + TelemetryTags.Database.User, + userCredentials?.Username ?? settings.DefaultCredentials?.Username + ); + + StartActivity(source, TracingConstants.Operations.Subscribe, ActivityKind.Consumer, tags, parentContext) + ?.Dispose(); + } + + static Activity? StartActivity( + this ActivitySource source, + string operationName, ActivityKind activityKind, ActivityTagsCollection? tags = null, + ActivityContext? parentContext = null + ) { + if (source.HasNoActiveListeners()) + return null; + + (tags ??= new ActivityTagsCollection()) + .WithRequiredTag(TelemetryTags.Database.System, "kurrent") + .WithRequiredTag(TelemetryTags.Database.Operation, operationName); + + return source + .CreateActivity( + operationName, + activityKind, + parentContext ?? default, + tags, + idFormat: ActivityIdFormat.W3C + ) + ?.Start(); + } + + static bool HasNoActiveListeners(this ActivitySource source) => !source.HasListeners(); +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/ActivityTagsCollectionExtensions.cs b/src/Kurrent.Client/Core/Common/Diagnostics/ActivityTagsCollectionExtensions.cs new file mode 100644 index 000000000..fd6ad661a --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/ActivityTagsCollectionExtensions.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Kurrent.Diagnostics; +using Kurrent.Diagnostics.Telemetry; + +namespace EventStore.Client.Diagnostics; + +static class ActivityTagsCollectionExtensions { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ActivityTagsCollection WithGrpcChannelServerTags(this ActivityTagsCollection tags, ChannelInfo? channelInfo) { + if (channelInfo is null) + return tags; + + var authorityParts = channelInfo.Channel.Target.Split(':'); + + return tags + .WithRequiredTag(TelemetryTags.Server.Address, authorityParts[0]) + .WithRequiredTag(TelemetryTags.Server.Port, int.Parse(authorityParts[1])); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ActivityTagsCollection WithClientSettingsServerTags(this ActivityTagsCollection source, KurrentClientSettings settings) { + if (settings.ConnectivitySettings.DnsGossipSeeds?.Length != 1) + return source; + + var gossipSeed = settings.ConnectivitySettings.DnsGossipSeeds[0]; + + return source + .WithRequiredTag(TelemetryTags.Server.Address, gossipSeed.Host) + .WithRequiredTag(TelemetryTags.Server.Port, gossipSeed.Port); + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityExtensions.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityExtensions.cs new file mode 100644 index 000000000..4b6404f60 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityExtensions.cs @@ -0,0 +1,52 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Kurrent.Diagnostics.Telemetry; +using Kurrent.Diagnostics.Tracing; + +namespace Kurrent.Diagnostics; + +static class ActivityExtensions { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TracingMetadata GetTracingMetadata(this Activity activity) => + new(activity.TraceId.ToString(), activity.SpanId.ToString()); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Activity StatusOk(this Activity activity, string? description = null) => + activity.SetActivityStatus(ActivityStatus.Ok(description)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Activity StatusError(this Activity activity, Exception exception) => + activity.SetActivityStatus(ActivityStatus.Error(exception)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static Activity RecordException(this Activity activity, Exception? exception) { + if (exception is null) return activity; + + var ex = exception is AggregateException aex ? aex.Flatten() : exception; + + var tags = new ActivityTagsCollection { + { TelemetryTags.Exception.Type, ex.GetType().FullName }, + { TelemetryTags.Exception.Stacktrace, ex.ToInvariantString() } + }; + + if (!string.IsNullOrWhiteSpace(exception.Message)) + tags.Add(TelemetryTags.Exception.Message, ex.Message); + + activity.AddEvent(new ActivityEvent(TelemetryTags.Exception.EventName, default, tags)); + + return activity; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static Activity SetActivityStatus(this Activity activity, ActivityStatus status) { + var statusCode = ActivityStatusCodeHelper.GetTagValueForStatusCode(status.StatusCode); + + activity.SetStatus(status.StatusCode, status.Description); + activity.SetTag(TelemetryTags.Otel.StatusCode, statusCode); + activity.SetTag(TelemetryTags.Otel.StatusDescription, status.Description); + + return activity.IsAllDataRequested ? activity.RecordException(status.Exception) : activity; + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityStatus.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityStatus.cs new file mode 100644 index 000000000..bab790b6c --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityStatus.cs @@ -0,0 +1,13 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; + +namespace Kurrent.Diagnostics; + +record ActivityStatus(ActivityStatusCode StatusCode, string? Description, Exception? Exception) { + public static ActivityStatus Ok(string? description = null) => + new(ActivityStatusCode.Ok, description, null); + + public static ActivityStatus Error(Exception exception, string? description = null) => + new(ActivityStatusCode.Error, description ?? exception.Message, exception); +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityStatusCodeHelper.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityStatusCodeHelper.cs new file mode 100644 index 000000000..592501d11 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityStatusCodeHelper.cs @@ -0,0 +1,24 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +using static System.Diagnostics.ActivityStatusCode; +using static System.StringComparison; + +namespace Kurrent.Diagnostics; + +static class ActivityStatusCodeHelper { + public const string UnsetStatusCodeTagValue = "UNSET"; + public const string OkStatusCodeTagValue = "OK"; + public const string ErrorStatusCodeTagValue = "ERROR"; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? GetTagValueForStatusCode(ActivityStatusCode statusCode) => + statusCode switch { + Unset => UnsetStatusCodeTagValue, + Error => ErrorStatusCodeTagValue, + Ok => OkStatusCodeTagValue, + _ => null + }; +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs new file mode 100644 index 000000000..2c8c8b291 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs @@ -0,0 +1,25 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Kurrent.Diagnostics; + +static class ActivityTagsCollectionExtensions { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ActivityTagsCollection WithRequiredTag(this ActivityTagsCollection source, string key, object? value) { + source[key] = value ?? throw new ArgumentNullException(key); + return source; + } + + /// + /// - If the key previously existed in the collection and the value is , the collection item matching the key will get removed from the collection. + /// - If the key previously existed in the collection and the value is not , the value will replace the old value stored in the collection. + /// - Otherwise, a new item will get added to the collection. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ActivityTagsCollection WithOptionalTag(this ActivityTagsCollection source, string key, object? value) { + source[key] = value; + return source; + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/ExceptionExtensions.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ExceptionExtensions.cs new file mode 100644 index 000000000..7eb397251 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/ExceptionExtensions.cs @@ -0,0 +1,25 @@ +// ReSharper disable CheckNamespace + +using System.Globalization; + +namespace Kurrent.Diagnostics; + +static class ExceptionExtensions { + /// + /// Returns a culture-independent string representation of the given object, + /// appropriate for diagnostics tracing. + /// + /// Exception to convert to string. + /// Exception as string with no culture. + public static string ToInvariantString(this Exception exception) { + var originalUiCulture = Thread.CurrentThread.CurrentUICulture; + + try { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + return exception.ToString(); + } + finally { + Thread.CurrentThread.CurrentUICulture = originalUiCulture; + } + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs new file mode 100644 index 000000000..54487e3bd --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs @@ -0,0 +1,35 @@ +// ReSharper disable CheckNamespace + +namespace Kurrent.Diagnostics.Telemetry; + +// The attributes below match the specification of v1.24.0 of the Open Telemetry semantic conventions. +// Some attributes are ignored where not required or relevant. +// https://github.com/open-telemetry/semantic-conventions/blob/v1.24.0/docs/general/trace.md +// https://github.com/open-telemetry/semantic-conventions/blob/v1.24.0/docs/database/database-spans.md +// https://github.com/open-telemetry/semantic-conventions/blob/v1.24.0/docs/exceptions/exceptions-spans.md + +static partial class TelemetryTags { + public static class Database { + public const string User = "db.user"; + public const string System = "db.system"; + public const string Operation = "db.operation"; + } + + public static class Server { + public const string Address = "server.address"; + public const string Port = "server.port"; + public const string SocketAddress = "server.socket.address"; // replaces: "net.peer.ip" (AttributeNetPeerIp) + } + + public static class Exception { + public const string EventName = "exception"; + public const string Type = "exception.type"; + public const string Message = "exception.message"; + public const string Stacktrace = "exception.stacktrace"; + } + + public static class Otel { + public const string StatusCode = "otel.status_code"; + public const string StatusDescription = "otel.status_description"; + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/Tracing/TracingConstants.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/Tracing/TracingConstants.cs new file mode 100644 index 000000000..1e94c9ef0 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/Tracing/TracingConstants.cs @@ -0,0 +1,10 @@ +// ReSharper disable CheckNamespace + +namespace Kurrent.Diagnostics.Tracing; + +static partial class TracingConstants { + public static class Metadata { + public const string TraceId = "$traceId"; + public const string SpanId = "$spanId"; + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Core/Tracing/TracingMetadata.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Core/Tracing/TracingMetadata.cs new file mode 100644 index 000000000..7580753a7 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Core/Tracing/TracingMetadata.cs @@ -0,0 +1,32 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace Kurrent.Diagnostics.Tracing; + +readonly record struct TracingMetadata( + [property: JsonPropertyName(TracingConstants.Metadata.TraceId)] + string? TraceId, + [property: JsonPropertyName(TracingConstants.Metadata.SpanId)] + string? SpanId +) { + public static readonly TracingMetadata None = new(null, null); + + [JsonIgnore] public bool IsValid => TraceId != null && SpanId != null; + + public ActivityContext? ToActivityContext(bool isRemote = true) { + try { + return IsValid + ? new ActivityContext( + ActivityTraceId.CreateFromString(new ReadOnlySpan(TraceId!.ToCharArray())), + ActivitySpanId.CreateFromString(new ReadOnlySpan(SpanId!.ToCharArray())), + ActivityTraceFlags.Recorded, + isRemote: isRemote + ) + : default; + } catch (Exception) { + return default; + } + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs b/src/Kurrent.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs new file mode 100644 index 000000000..d45b53156 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Kurrent.Diagnostics; +using Kurrent.Diagnostics.Tracing; + +namespace EventStore.Client.Diagnostics; + +static class EventMetadataExtensions { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ReadOnlySpan InjectTracingContext( + this ReadOnlyMemory eventMetadata, Activity? activity + ) => + eventMetadata.InjectTracingMetadata(activity?.GetTracingMetadata() ?? TracingMetadata.None); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ActivityContext? ExtractPropagationContext(this ReadOnlyMemory eventMetadata) => + eventMetadata.ExtractTracingMetadata().ToActivityContext(isRemote: true); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TracingMetadata ExtractTracingMetadata(this ReadOnlyMemory eventMetadata) { + if (eventMetadata.IsEmpty) + return TracingMetadata.None; + + var reader = new Utf8JsonReader(eventMetadata.Span); + try { + if (!JsonDocument.TryParseValue(ref reader, out var doc)) + return TracingMetadata.None; + + using (doc) { + if (!doc.RootElement.TryGetProperty(TracingConstants.Metadata.TraceId, out var traceId) + || !doc.RootElement.TryGetProperty(TracingConstants.Metadata.SpanId, out var spanId)) + return TracingMetadata.None; + + return new TracingMetadata(traceId.GetString(), spanId.GetString()); + } + } catch (Exception) { + return TracingMetadata.None; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static ReadOnlySpan InjectTracingMetadata( + this ReadOnlyMemory eventMetadata, TracingMetadata tracingMetadata + ) { + if (tracingMetadata == TracingMetadata.None || !tracingMetadata.IsValid) + return eventMetadata.Span; + + return eventMetadata.IsEmpty + ? JsonSerializer.SerializeToUtf8Bytes(tracingMetadata) + : TryInjectTracingMetadata(eventMetadata, tracingMetadata).ToArray(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static ReadOnlyMemory TryInjectTracingMetadata( + this ReadOnlyMemory utf8Json, TracingMetadata tracingMetadata + ) { + try { + using var doc = JsonDocument.Parse(utf8Json); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + writer.WriteStartObject(); + + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return utf8Json; + + foreach (var prop in doc.RootElement.EnumerateObject()) + prop.WriteTo(writer); + + writer.WritePropertyName(TracingConstants.Metadata.TraceId); + writer.WriteStringValue(tracingMetadata.TraceId); + writer.WritePropertyName(TracingConstants.Metadata.SpanId); + writer.WriteStringValue(tracingMetadata.SpanId); + + writer.WriteEndObject(); + writer.Flush(); + + return stream.ToArray(); + } catch (Exception) { + return utf8Json; + } + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/KurrentClientDiagnostics.cs b/src/Kurrent.Client/Core/Common/Diagnostics/KurrentClientDiagnostics.cs new file mode 100644 index 000000000..48e437463 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/KurrentClientDiagnostics.cs @@ -0,0 +1,8 @@ +using System.Diagnostics; + +namespace EventStore.Client.Diagnostics; + +public static class KurrentClientDiagnostics { + public const string InstrumentationName = "kurrent"; + public static readonly ActivitySource ActivitySource = new(InstrumentationName); +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Telemetry/TelemetryTags.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Telemetry/TelemetryTags.cs new file mode 100644 index 000000000..e4e0b11f9 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Telemetry/TelemetryTags.cs @@ -0,0 +1,12 @@ +// ReSharper disable CheckNamespace + +namespace Kurrent.Diagnostics.Telemetry; + +static partial class TelemetryTags { + public static class Kurrent { + public const string Stream = "db.kurrent.stream"; + public const string SubscriptionId = "db.kurrent.subscription.id"; + public const string EventId = "db.kurrent.event.id"; + public const string EventType = "db.kurrent.event.type"; + } +} diff --git a/src/Kurrent.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs b/src/Kurrent.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs new file mode 100644 index 000000000..357654570 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs @@ -0,0 +1,10 @@ +// ReSharper disable CheckNamespace + +namespace Kurrent.Diagnostics.Tracing; + +static partial class TracingConstants { + public static class Operations { + public const string Append = "streams.append"; + public const string Subscribe = "streams.subscribe"; + } +} diff --git a/src/Kurrent.Client/Core/Common/EnumerableTaskExtensions.cs b/src/Kurrent.Client/Core/Common/EnumerableTaskExtensions.cs new file mode 100644 index 000000000..5be066ab5 --- /dev/null +++ b/src/Kurrent.Client/Core/Common/EnumerableTaskExtensions.cs @@ -0,0 +1,11 @@ +using System.Diagnostics; + +namespace EventStore.Client; + +static class EnumerableTaskExtensions { + [DebuggerStepThrough] + public static Task WhenAll(this IEnumerable source) => Task.WhenAll(source); + + [DebuggerStepThrough] + public static Task WhenAll(this IEnumerable> source) => Task.WhenAll(source); +} diff --git a/src/Kurrent.Client/Core/Common/EpochExtensions.cs b/src/Kurrent.Client/Core/Common/EpochExtensions.cs new file mode 100644 index 000000000..0643bcecb --- /dev/null +++ b/src/Kurrent.Client/Core/Common/EpochExtensions.cs @@ -0,0 +1,25 @@ +namespace EventStore.Client; + +static class EpochExtensions { +#if NET + static readonly DateTime UnixEpoch = DateTime.UnixEpoch; +#else + const long TicksPerMillisecond = 10000; + const long TicksPerSecond = TicksPerMillisecond * 1000; + const long TicksPerMinute = TicksPerSecond * 60; + const long TicksPerHour = TicksPerMinute * 60; + const long TicksPerDay = TicksPerHour * 24; + const int DaysPerYear = 365; + const int DaysPer4Years = DaysPerYear * 4 + 1; + const int DaysPer100Years = DaysPer4Years * 25 - 1; + const int DaysPer400Years = DaysPer100Years * 4 + 1; + const int DaysTo1970 = DaysPer400Years * 4 + DaysPer100Years * 3 + DaysPer4Years * 17 + DaysPerYear; + const long UnixEpochTicks = DaysTo1970 * TicksPerDay; + + static readonly DateTime UnixEpoch = new(UnixEpochTicks, DateTimeKind.Utc); +#endif + + public static DateTime FromTicksSinceEpoch(this long value) => new(UnixEpoch.Ticks + value, DateTimeKind.Utc); + + public static long ToTicksSinceEpoch(this DateTime value) => (value - UnixEpoch).Ticks; +} diff --git a/src/Kurrent.Client/Core/Common/KurrentCallOptions.cs b/src/Kurrent.Client/Core/Common/KurrentCallOptions.cs new file mode 100644 index 000000000..0644cffea --- /dev/null +++ b/src/Kurrent.Client/Core/Common/KurrentCallOptions.cs @@ -0,0 +1,74 @@ +using Grpc.Core; +using static System.Threading.Timeout; + +namespace EventStore.Client; + +static class KurrentCallOptions { + // deadline falls back to infinity + public static CallOptions CreateStreaming( + KurrentClientSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) => + Create(settings, deadline, userCredentials, cancellationToken); + + // deadline falls back to connection DefaultDeadline + public static CallOptions CreateNonStreaming( + KurrentClientSettings settings, + CancellationToken cancellationToken + ) => + Create( + settings, + settings.DefaultDeadline, + settings.DefaultCredentials, + cancellationToken + ); + + public static CallOptions CreateNonStreaming( + KurrentClientSettings settings, TimeSpan? deadline, + UserCredentials? userCredentials, CancellationToken cancellationToken + ) => + Create( + settings, + deadline ?? settings.DefaultDeadline, + userCredentials, + cancellationToken + ); + + static CallOptions Create( + KurrentClientSettings settings, TimeSpan? deadline, + UserCredentials? userCredentials, CancellationToken cancellationToken + ) => + new( + cancellationToken: cancellationToken, + deadline: DeadlineAfter(deadline), + headers: new() { + { + Constants.Headers.RequiresLeader, + settings.ConnectivitySettings.NodePreference == NodePreference.Leader + ? bool.TrueString + : bool.FalseString + } + }, + credentials: (userCredentials ?? settings.DefaultCredentials) == null + ? null + : CallCredentials.FromInterceptor( + async (_, metadata) => { + var credentials = userCredentials ?? settings.DefaultCredentials; + + var authorizationHeader = await settings.OperationOptions + .GetAuthenticationHeaderValue(credentials!, CancellationToken.None) + .ConfigureAwait(false); + + metadata.Add(Constants.Headers.Authorization, authorizationHeader); + } + ) + ); + + static DateTime? DeadlineAfter(TimeSpan? timeoutAfter) => + !timeoutAfter.HasValue + ? new DateTime?() + : timeoutAfter.Value == TimeSpan.MaxValue || timeoutAfter.Value == InfiniteTimeSpan + ? DateTime.MaxValue + : DateTime.UtcNow.Add(timeoutAfter.Value); +} diff --git a/src/Kurrent.Client/Core/Common/MetadataExtensions.cs b/src/Kurrent.Client/Core/Common/MetadataExtensions.cs new file mode 100644 index 000000000..e7311c37f --- /dev/null +++ b/src/Kurrent.Client/Core/Common/MetadataExtensions.cs @@ -0,0 +1,29 @@ +using Grpc.Core; + +namespace EventStore.Client; + +static class MetadataExtensions { + public static bool TryGetValue(this Metadata metadata, string key, out string? value) { + value = default; + + foreach (var entry in metadata) { + if (entry.Key != key) + continue; + + value = entry.Value; + return true; + } + + return false; + } + + public static StreamRevision GetStreamRevision(this Metadata metadata, string key) => + metadata.TryGetValue(key, out var s) && ulong.TryParse(s, out var value) + ? new(value) + : StreamRevision.None; + + public static int GetIntValueOrDefault(this Metadata metadata, string key) => + metadata.TryGetValue(key, out var s) && int.TryParse(s, out var value) + ? value + : default; +} diff --git a/src/Kurrent.Client/Core/Common/Shims/Index.cs b/src/Kurrent.Client/Core/Common/Shims/Index.cs new file mode 100644 index 000000000..357bbd34d --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Shims/Index.cs @@ -0,0 +1,110 @@ +#if !NET +using System.Runtime.CompilerServices; + +namespace System; + +/// Represent a type can be used to index a collection either from the start or the end. +/// +/// Index is used by the C# compiler to support the new index syntax +/// +/// int[] someArray = new int[5] { 1, 2, 3, 4, 5 } ; +/// int lastElement = someArray[^1]; // lastElement = 5 +/// +/// +readonly struct Index : IEquatable { + readonly int _value; + + /// Construct an Index using a value and indicating if the index is from the start or from the end. + /// The index value. it has to be zero or positive number. + /// Indicating if the index is from the start or from the end. + /// + /// If the Index constructed from the end, index value 1 means pointing at the last element and index value 0 means pointing at beyond last element. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Index(int value, bool fromEnd = false) { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + + if (fromEnd) + _value = ~value; + else + _value = value; + } + + // The following private constructors mainly created for perf reason to avoid the checks + Index(int value) => _value = value; + + /// Create an Index pointing at first element. + public static Index Start => new(0); + + /// Create an Index pointing at beyond last element. + public static Index End => new(~0); + + /// Create an Index from the start at the position indicated by the value. + /// The index value from the start. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromStart(int value) { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + + return new Index(value); + } + + /// Create an Index from the end at the position indicated by the value. + /// The index value from the end. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromEnd(int value) { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + + return new Index(~value); + } + + /// Returns the index value. + public int Value { + get { + if (_value < 0) + return ~_value; + else + return _value; + } + } + + /// Indicates whether the index is from the start or the end. + public bool IsFromEnd => _value < 0; + + /// Calculate the offset from the start using the giving collection length. + /// The length of the collection that the Index will be used with. length has to be a positive value + /// + /// For performance reason, we don't validate the input length parameter and the returned offset value against negative values. + /// we don't validate either the returned offset is greater than the input length. + /// It is expected Index will be used with collections which always have non negative length/count. If the returned offset is negative and + /// then used to index a collection will get out of range exception which will be same affect as the validation. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetOffset(int length) { + var offset = _value; + if (IsFromEnd) + // offset = length - (~value) + // offset = length + (~(~value) + 1) + // offset = length + value + 1 + offset += length + 1; + + return offset; + } + + /// Indicates whether the current Index object is equal to another object of the same type. + /// An object to compare with this object + public override bool Equals(object? value) => value is Index && _value == ((Index)value)._value; + + /// Indicates whether the current Index object is equal to another Index object. + /// An object to compare with this object + public bool Equals(Index other) => _value == other._value; + + /// Returns the hash code for this instance. + public override int GetHashCode() => _value; + + /// Converts integer number to an Index. + public static implicit operator Index(int value) => FromStart(value); + + /// Converts the value of the current Index object to its equivalent string representation. + public override string ToString() => IsFromEnd ? $"^{(uint)Value}" : ((uint)Value).ToString(); +} +#endif \ No newline at end of file diff --git a/src/Kurrent.Client/Core/Common/Shims/IsExternalInit.cs b/src/Kurrent.Client/Core/Common/Shims/IsExternalInit.cs new file mode 100644 index 000000000..7dc4fea3d --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Shims/IsExternalInit.cs @@ -0,0 +1,10 @@ +#if !NET + +using System.ComponentModel; + +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; + +[EditorBrowsable(EditorBrowsableState.Never)] +class IsExternalInit{} +#endif diff --git a/src/Kurrent.Client/Core/Common/Shims/Range.cs b/src/Kurrent.Client/Core/Common/Shims/Range.cs new file mode 100644 index 000000000..3a0b34fde --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Shims/Range.cs @@ -0,0 +1,75 @@ +#if !NET +// ReSharper disable CheckNamespace + +using System.Runtime.CompilerServices; + +namespace System; + +/// Represent a range has start and end indexes. +/// +/// Range is used by the C# compiler to support the range syntax. +/// +/// int[] someArray = new int[5] { 1, 2, 3, 4, 5 }; +/// int[] subArray1 = someArray[0..2]; // { 1, 2 } +/// int[] subArray2 = someArray[1..^0]; // { 2, 3, 4, 5 } +/// +/// +readonly struct Range : IEquatable { + /// Represent the inclusive start index of the Range. + public Index Start { get; } + + /// Represent the exclusive end index of the Range. + public Index End { get; } + + /// Construct a Range object using the start and end indexes. + /// Represent the inclusive start index of the range. + /// Represent the exclusive end index of the range. + public Range(Index start, Index end) { + Start = start; + End = end; + } + + /// Indicates whether the current Range object is equal to another object of the same type. + /// An object to compare with this object + public override bool Equals(object? value) => + value is Range r && + r.Start.Equals(Start) && + r.End.Equals(End); + + /// Indicates whether the current Range object is equal to another Range object. + /// An object to compare with this object + public bool Equals(Range other) => other.Start.Equals(Start) && other.End.Equals(End); + + /// Returns the hash code for this instance. + public override int GetHashCode() => Start.GetHashCode() * 31 + End.GetHashCode(); + + /// Converts the value of the current Range object to its equivalent string representation. + public override string ToString() => $"{Start}..{End}"; + + /// Create a Range object starting from start index to the end of the collection. + public static Range StartAt(Index start) => new(start, Index.End); + + /// Create a Range object starting from first element in the collection to the end Index. + public static Range EndAt(Index end) => new(Index.Start, end); + + /// Create a Range object starting from first element to the end. + public static Range All => new(Index.Start, Index.End); + + /// Calculate the start offset and length of range object using a collection length. + /// The length of the collection that the range will be used with. length has to be a positive value. + /// + /// For performance reason, we don't validate the input length parameter against negative values. + /// It is expected Range will be used with collections which always have non negative length/count. + /// We validate the range is inside the length scope though. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public (int Offset, int Length) GetOffsetAndLength(int length) { + var start = Start.GetOffset(length); + var end = End.GetOffset(length); + + if ((uint)end > (uint)length || (uint)start > (uint)end) throw new ArgumentOutOfRangeException(nameof(length)); + + return (start, end - start); + } +} +#endif \ No newline at end of file diff --git a/src/Kurrent.Client/Core/Common/Shims/TaskCompletionSource.cs b/src/Kurrent.Client/Core/Common/Shims/TaskCompletionSource.cs new file mode 100644 index 000000000..ad6573c4a --- /dev/null +++ b/src/Kurrent.Client/Core/Common/Shims/TaskCompletionSource.cs @@ -0,0 +1,11 @@ +#if !NET +// ReSharper disable CheckNamespace + +namespace System.Threading.Tasks; + +class TaskCompletionSource : TaskCompletionSource { + public void SetResult() => base.SetResult(null); + public bool TrySetResult() => base.TrySetResult(null); +} + +#endif \ No newline at end of file diff --git a/src/Kurrent.Client/Core/DefaultRequestVersionHandler.cs b/src/Kurrent.Client/Core/DefaultRequestVersionHandler.cs new file mode 100644 index 000000000..b6cda6a7f --- /dev/null +++ b/src/Kurrent.Client/Core/DefaultRequestVersionHandler.cs @@ -0,0 +1,14 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace EventStore.Client { + internal class DefaultRequestVersionHandler : DelegatingHandler { + public DefaultRequestVersionHandler(HttpMessageHandler innerHandler) : base(innerHandler) { } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + request.Version = new Version(2, 0); + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Kurrent.Client/Core/EndPointExtensions.cs b/src/Kurrent.Client/Core/EndPointExtensions.cs new file mode 100644 index 000000000..8ebad38e0 --- /dev/null +++ b/src/Kurrent.Client/Core/EndPointExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Net; + +namespace EventStore.Client { + internal static class EndPointExtensions { + public static string GetHost(this EndPoint endpoint) => + endpoint switch { + IPEndPoint ip => ip.Address.ToString(), + DnsEndPoint dns => dns.Host, + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint?.GetType(), + "An invalid endpoint has been provided") + }; + + public static int GetPort(this EndPoint endpoint) => + endpoint switch { + IPEndPoint ip => ip.Port, + DnsEndPoint dns => dns.Port, + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint?.GetType(), + "An invalid endpoint has been provided") + }; + + public static Uri ToUri(this EndPoint endPoint, bool https) => new UriBuilder { + Scheme = https ? Uri.UriSchemeHttps : Uri.UriSchemeHttp, + Host = endPoint.GetHost(), + Port = endPoint.GetPort() + }.Uri; + + public static string? ToHttpUrl(this EndPoint endPoint, string schema, string? rawUrl = null) => + endPoint switch { + IPEndPoint ipEndPoint => CreateHttpUrl(schema, ipEndPoint.Address.ToString(), ipEndPoint.Port, + rawUrl != null ? rawUrl.TrimStart('/') : string.Empty), + DnsEndPoint dnsEndpoint => CreateHttpUrl(schema, dnsEndpoint.Host, dnsEndpoint.Port, + rawUrl != null ? rawUrl.TrimStart('/') : string.Empty), + _ => null + }; + + private static string CreateHttpUrl(string schema, string host, int port, string path) { + return $"{schema}://{host}:{port}/{path}"; + } + } +} diff --git a/src/Kurrent.Client/Core/EventData.cs b/src/Kurrent.Client/Core/EventData.cs new file mode 100644 index 000000000..3849ff364 --- /dev/null +++ b/src/Kurrent.Client/Core/EventData.cs @@ -0,0 +1,65 @@ +using System; +using System.Net.Http.Headers; + +namespace EventStore.Client { + /// + /// Represents an event to be written. + /// + public sealed class EventData { + /// + /// The raw bytes of the event data. + /// + public readonly ReadOnlyMemory Data; + + /// + /// The of the event, used as part of the idempotent write check. + /// + public readonly Uuid EventId; + + /// + /// The raw bytes of the event metadata. + /// + public readonly ReadOnlyMemory Metadata; + + /// + /// The name of the event type. It is strongly recommended that these + /// use lowerCamelCase if projections are to be used. + /// + public readonly string Type; + + /// + /// The Content-Type of the . Valid values are 'application/json' and 'application/octet-stream'. + /// + public readonly string ContentType; + + /// + /// Constructs a new . + /// + /// The of the event, used as part of the idempotent write check. + /// The name of the event type. It is strongly recommended that these use lowerCamelCase if projections are to be used. + /// The raw bytes of the event data. + /// The raw bytes of the event metadata. + /// The Content-Type of the . Valid values are 'application/json' and 'application/octet-stream'. + /// + public EventData(Uuid eventId, string type, ReadOnlyMemory data, ReadOnlyMemory? metadata = null, + string contentType = Constants.Metadata.ContentTypes.ApplicationJson) { + if (eventId == Uuid.Empty) { + throw new ArgumentOutOfRangeException(nameof(eventId)); + } + + MediaTypeHeaderValue.Parse(contentType); + + if (contentType != Constants.Metadata.ContentTypes.ApplicationJson && + contentType != Constants.Metadata.ContentTypes.ApplicationOctetStream) { + throw new ArgumentOutOfRangeException(nameof(contentType), contentType, + $"Only {Constants.Metadata.ContentTypes.ApplicationJson} or {Constants.Metadata.ContentTypes.ApplicationOctetStream} are acceptable values."); + } + + EventId = eventId; + Type = type; + Data = data; + Metadata = metadata ?? Array.Empty(); + ContentType = contentType; + } + } +} diff --git a/src/Kurrent.Client/Core/EventRecord.cs b/src/Kurrent.Client/Core/EventRecord.cs new file mode 100644 index 000000000..531362661 --- /dev/null +++ b/src/Kurrent.Client/Core/EventRecord.cs @@ -0,0 +1,80 @@ +namespace EventStore.Client { + /// + /// Represents a previously written event. + /// + public class EventRecord { + /// + /// The stream that this event belongs to. + /// + public readonly string EventStreamId; + + /// + /// The representing this event. + /// + public readonly Uuid EventId; + + /// + /// The of this event in the stream. + /// + public readonly StreamPosition EventNumber; + + /// + /// The type of event this is. + /// + public readonly string EventType; + + /// + /// The raw bytes representing the data of this event. + /// + public readonly ReadOnlyMemory Data; + + /// + /// The raw bytes representing the metadata of this event. + /// + public readonly ReadOnlyMemory Metadata; + + /// + /// A UTC representing when this event was created in the system. + /// + public readonly DateTime Created; + + /// + /// The of this event in the $all stream. + /// + public readonly Position Position; + + /// + /// The Content-Type of the event's data. + /// + public readonly string ContentType; + + /// + /// Constructs a new . + /// + /// + /// + /// + /// + /// + /// + /// + public EventRecord( + string eventStreamId, + Uuid eventId, + StreamPosition eventNumber, + Position position, + IDictionary metadata, + ReadOnlyMemory data, + ReadOnlyMemory customMetadata) { + EventStreamId = eventStreamId; + EventId = eventId; + EventNumber = eventNumber; + Position = position; + Data = data; + Metadata = customMetadata; + EventType = metadata[Constants.Metadata.Type]; + Created = Convert.ToInt64(metadata[Constants.Metadata.Created]).FromTicksSinceEpoch(); + ContentType = metadata[Constants.Metadata.ContentType]; + } + } +} diff --git a/src/Kurrent.Client/Core/EventTypeFilter.cs b/src/Kurrent.Client/Core/EventTypeFilter.cs new file mode 100644 index 000000000..1e53e84e9 --- /dev/null +++ b/src/Kurrent.Client/Core/EventTypeFilter.cs @@ -0,0 +1,145 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace EventStore.Client { + /// + /// A structure representing a filter on event types for read operations. + /// + public readonly struct EventTypeFilter : IEquatable, IEventFilter { + /// + /// An empty . + /// + public static readonly EventTypeFilter None = default; + + readonly PrefixFilterExpression[] _prefixes; + + + /// + public PrefixFilterExpression[] Prefixes => _prefixes ?? Array.Empty(); + + /// + public RegularFilterExpression Regex { get; } + + /// + public uint? MaxSearchWindow { get; } + + /// + /// An that excludes system events (i.e., those whose types start with $). + /// + /// + /// + public static EventTypeFilter ExcludeSystemEvents(uint maxSearchWindow = 32) => + new EventTypeFilter(maxSearchWindow, RegularFilterExpression.ExcludeSystemEvents); + + /// + /// Creates an from a single prefix. + /// + /// + /// + public static IEventFilter Prefix(string prefix) + => new EventTypeFilter(new PrefixFilterExpression(prefix)); + + /// + /// Creates an from multiple prefixes. + /// + /// + /// + public static IEventFilter Prefix(params string[] prefixes) + => new EventTypeFilter(Array.ConvertAll(prefixes, prefix => new PrefixFilterExpression(prefix))); + + /// + /// Creates an from a search window and multiple prefixes. + /// + /// + /// + /// + public static IEventFilter Prefix(uint maxSearchWindow, params string[] prefixes) + => new EventTypeFilter(maxSearchWindow, + Array.ConvertAll(prefixes, prefix => new PrefixFilterExpression(prefix))); + + /// + /// Creates an from a regular expression and a search window. + /// + /// + /// + /// + public static IEventFilter RegularExpression(string regex, uint maxSearchWindow = 32) + => new EventTypeFilter(maxSearchWindow, new RegularFilterExpression(regex)); + + /// + /// Creates an from a regular expression and a search window. + /// + /// + /// + /// + public static IEventFilter RegularExpression(Regex regex, uint maxSearchWindow = 32) + => new EventTypeFilter(maxSearchWindow, new RegularFilterExpression(regex)); + + EventTypeFilter(uint maxSearchWindow, RegularFilterExpression regex) { + if (maxSearchWindow == 0) { + throw new ArgumentOutOfRangeException(nameof(maxSearchWindow), + maxSearchWindow, $"{nameof(maxSearchWindow)} must be greater than 0."); + } + + Regex = regex; + _prefixes = Array.Empty(); + MaxSearchWindow = maxSearchWindow; + } + + EventTypeFilter(params PrefixFilterExpression[] prefixes) : this(32, prefixes) { } + + EventTypeFilter(uint maxSearchWindow, params PrefixFilterExpression[] prefixes) { + if (prefixes.Length == 0) { + throw new ArgumentException(); + } + + if (maxSearchWindow == 0) { + throw new ArgumentOutOfRangeException(nameof(maxSearchWindow), + maxSearchWindow, $"{nameof(maxSearchWindow)} must be greater than 0."); + } + + _prefixes = prefixes; + Regex = RegularFilterExpression.None; + MaxSearchWindow = maxSearchWindow; + } + + /// + public bool Equals(EventTypeFilter other) => + Prefixes == null || other.Prefixes == null + ? Prefixes == other.Prefixes && + Regex.Equals(other.Regex) && + MaxSearchWindow.Equals(other.MaxSearchWindow) + : Prefixes.SequenceEqual(other.Prefixes) && + Regex.Equals(other.Regex) && + MaxSearchWindow.Equals(other.MaxSearchWindow); + + /// + public override bool Equals(object? obj) => obj is EventTypeFilter other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(Prefixes).Combine(Regex).Combine(MaxSearchWindow); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(EventTypeFilter left, EventTypeFilter right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(EventTypeFilter left, EventTypeFilter right) => !left.Equals(right); + + /// + public override string ToString() => + this == None + ? "(none)" + : $"{nameof(EventTypeFilter)} {(Prefixes.Length == 0 ? Regex.ToString() : $"[{string.Join(", ", Prefixes)}]")}"; + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/AccessDeniedException.cs b/src/Kurrent.Client/Core/Exceptions/AccessDeniedException.cs new file mode 100644 index 000000000..cfd69ee6d --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/AccessDeniedException.cs @@ -0,0 +1,22 @@ +using System; + +namespace EventStore.Client { + /// + /// Exception thrown when a user is not authorised to carry out + /// an operation. + /// + public class AccessDeniedException : Exception { + /// + /// Constructs a new . + /// + public AccessDeniedException(string message, Exception innerException) : base(message, innerException) { + } + + /// + /// Constructs a new . + /// + public AccessDeniedException() : base("Access denied.") { + + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/ConnectionStringParseException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/ConnectionStringParseException.cs new file mode 100644 index 000000000..2cb8c1ac5 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/ConnectionStringParseException.cs @@ -0,0 +1,15 @@ +using System; + +namespace EventStore.Client { + /// + /// The base exception that is thrown when an KurrentDB connection string could not be parsed. + /// + public class ConnectionStringParseException : Exception { + /// + /// Constructs a new . + /// + /// + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public ConnectionStringParseException(string message, Exception? innerException = null) : base(message, innerException) { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/DuplicateKeyException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/DuplicateKeyException.cs new file mode 100644 index 000000000..5cc3c3812 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/DuplicateKeyException.cs @@ -0,0 +1,13 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when a key in the KurrentDB connection string is duplicated. + /// + public class DuplicateKeyException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + /// + public DuplicateKeyException(string key) + : base($"Duplicate key: '{key}'") { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidClientCertificateException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidClientCertificateException.cs new file mode 100644 index 000000000..1caf9f9fb --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidClientCertificateException.cs @@ -0,0 +1,14 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when a certificate is invalid or not found in the KurrentDB connection string. + /// + public class InvalidClientCertificateException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + /// + /// + public InvalidClientCertificateException(string message, Exception? innerException = null) + : base(message, innerException) { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidHostException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidHostException.cs new file mode 100644 index 000000000..696a81c32 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidHostException.cs @@ -0,0 +1,13 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when there is an invalid host in the KurrentDB connection string. + /// + public class InvalidHostException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + /// + public InvalidHostException(string host) + : base($"Invalid host: '{host}'") { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidKeyValuePairException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidKeyValuePairException.cs new file mode 100644 index 000000000..d00e08445 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidKeyValuePairException.cs @@ -0,0 +1,13 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when an invalid key value pair is found in an KurrentDB connection string. + /// + public class InvalidKeyValuePairException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + /// + public InvalidKeyValuePairException(string keyValuePair) + : base($"Invalid key/value pair: '{keyValuePair}'") { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidSchemeException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidSchemeException.cs new file mode 100644 index 000000000..3b5cf2b18 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidSchemeException.cs @@ -0,0 +1,14 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when an invalid scheme is defined in the KurrentDB connection string. + /// + public class InvalidSchemeException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + /// + /// + public InvalidSchemeException(string scheme, string[] supportedSchemes) + : base($"Invalid scheme: '{scheme}'. Supported values are: {string.Join(",", supportedSchemes)}") { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidSettingException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidSettingException.cs new file mode 100644 index 000000000..71dcc6edc --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidSettingException.cs @@ -0,0 +1,12 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when an invalid setting is found in an KurrentDB connection string. + /// + public class InvalidSettingException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + /// + public InvalidSettingException(string message) : base(message) { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidUserCredentialsException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidUserCredentialsException.cs new file mode 100644 index 000000000..1d4544a61 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/InvalidUserCredentialsException.cs @@ -0,0 +1,13 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when an invalid is specified in the KurrentDB connection string. + /// + public class InvalidUserCredentialsException : ConnectionStringParseException { + /// + /// + /// + /// + public InvalidUserCredentialsException(string userInfo) + : base($"Invalid user credentials: '{userInfo}'. Username & password must be delimited by a colon") { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ConnectionString/NoSchemeException.cs b/src/Kurrent.Client/Core/Exceptions/ConnectionString/NoSchemeException.cs new file mode 100644 index 000000000..08432be97 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ConnectionString/NoSchemeException.cs @@ -0,0 +1,12 @@ +namespace EventStore.Client { + /// + /// The exception that is thrown when no scheme was specified in the KurrentDB connection string. + /// + public class NoSchemeException : ConnectionStringParseException { + /// + /// Constructs a new . + /// + public NoSchemeException() + : base("Could not parse scheme from connection string") { } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/DiscoveryException.cs b/src/Kurrent.Client/Core/Exceptions/DiscoveryException.cs new file mode 100644 index 000000000..f3f490adb --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/DiscoveryException.cs @@ -0,0 +1,33 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when discovery fails. + /// + public class DiscoveryException : Exception { + /// + /// The configured number of discovery attempts. + /// + public int MaxDiscoverAttempts { get; } + + /// + /// Constructs a new . + /// + /// + /// + [Obsolete] + public DiscoveryException(string message, Exception? innerException = null) + : base(message, innerException) { + MaxDiscoverAttempts = 0; + } + + /// + /// Constructs a new . + /// + /// The configured number of discovery attempts. + public DiscoveryException(int maxDiscoverAttempts) : base( + $"Failed to discover candidate in {maxDiscoverAttempts} attempts.") { + MaxDiscoverAttempts = maxDiscoverAttempts; + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/NotAuthenticatedException.cs b/src/Kurrent.Client/Core/Exceptions/NotAuthenticatedException.cs new file mode 100644 index 000000000..289cfa230 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/NotAuthenticatedException.cs @@ -0,0 +1,16 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when a user is not authenticated. + /// + public class NotAuthenticatedException : Exception { + /// + /// Constructs a new . + /// + /// + /// + public NotAuthenticatedException(string message, Exception? innerException = null) : base(message, innerException) { + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/NotLeaderException.cs b/src/Kurrent.Client/Core/Exceptions/NotLeaderException.cs new file mode 100644 index 000000000..4ad5ca32a --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/NotLeaderException.cs @@ -0,0 +1,26 @@ +using System; +using System.Net; + +namespace EventStore.Client { + /// + /// The exception that is thrown when an operation requiring a leader node is made on a follower node. + /// + public class NotLeaderException : Exception { + + /// + /// The of the current leader node. + /// + public DnsEndPoint LeaderEndpoint { get; } + + /// + /// Constructs a new + /// + /// + /// + /// + public NotLeaderException(string host, int port, Exception? exception = null) : base( + $"Not leader. New leader at {host}:{port}.", exception) { + LeaderEndpoint = new DnsEndPoint(host, port); + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/RequiredMetadataPropertyMissingException.cs b/src/Kurrent.Client/Core/Exceptions/RequiredMetadataPropertyMissingException.cs new file mode 100644 index 000000000..079eb0444 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/RequiredMetadataPropertyMissingException.cs @@ -0,0 +1,18 @@ +using System; + +namespace EventStore.Client { + /// + /// Exception thrown when a required metadata property is missing. + /// + public class RequiredMetadataPropertyMissingException : Exception { + /// + /// Constructs a new . + /// + /// + /// + public RequiredMetadataPropertyMissingException(string missingMetadataProperty, + Exception? innerException = null) : + base($"Required metadata property {missingMetadataProperty} is missing", innerException) { + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/ScavengeNotFoundException.cs b/src/Kurrent.Client/Core/Exceptions/ScavengeNotFoundException.cs new file mode 100644 index 000000000..06a629661 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/ScavengeNotFoundException.cs @@ -0,0 +1,23 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when attempting to see the status of a scavenge operation that does not exist. + /// + public class ScavengeNotFoundException : Exception { + /// + /// The id of the scavenge operation. + /// + public string? ScavengeId { get; } + + /// + /// Constructs a new . + /// + /// + /// + public ScavengeNotFoundException(string? scavengeId, Exception? exception = null) : base( + $"Scavenge not found. The currently running scavenge is {scavengeId ?? ""}.", exception) { + ScavengeId = scavengeId; + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/StreamDeletedException.cs b/src/Kurrent.Client/Core/Exceptions/StreamDeletedException.cs new file mode 100644 index 000000000..0b892578f --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/StreamDeletedException.cs @@ -0,0 +1,24 @@ +using System; + +namespace EventStore.Client { + /// + /// Exception thrown if an operation is attempted on a stream which + /// has been deleted. + /// + public class StreamDeletedException : Exception { + /// + /// The name of the deleted stream. + /// + public readonly string Stream; + + /// + /// Constructs a new instance of . + /// + /// The name of the deleted stream. + /// + public StreamDeletedException(string stream, Exception? exception = null) + : base($"Event stream '{stream}' is deleted.", exception) { + Stream = stream; + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/StreamNotFoundException.cs b/src/Kurrent.Client/Core/Exceptions/StreamNotFoundException.cs new file mode 100644 index 000000000..dc844df36 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/StreamNotFoundException.cs @@ -0,0 +1,23 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when an attempt is made to read or write to a stream that does not exist. + /// + public class StreamNotFoundException : Exception { + /// + /// The name of the stream. + /// + public readonly string Stream; + + /// + /// Constructs a new instance of . + /// + /// The name of the stream. + /// + public StreamNotFoundException(string stream, Exception? exception = null) + : base($"Event stream '{stream}' was not found.", exception) { + Stream = stream; + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/UserNotFoundException.cs b/src/Kurrent.Client/Core/Exceptions/UserNotFoundException.cs new file mode 100644 index 000000000..3caa6cb68 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/UserNotFoundException.cs @@ -0,0 +1,23 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when an operation is performed on an internal user that does not exist. + /// + public class UserNotFoundException : Exception { + /// + /// The login name of the user. + /// + public string LoginName { get; } + + /// + /// Constructs a new . + /// + /// + /// + public UserNotFoundException(string loginName, Exception? exception = null) + : base($"User '{loginName}' was not found.", exception) { + LoginName = loginName; + } + } +} diff --git a/src/Kurrent.Client/Core/Exceptions/WrongExpectedVersionException.cs b/src/Kurrent.Client/Core/Exceptions/WrongExpectedVersionException.cs new file mode 100644 index 000000000..ea2489e63 --- /dev/null +++ b/src/Kurrent.Client/Core/Exceptions/WrongExpectedVersionException.cs @@ -0,0 +1,66 @@ +using System; + +namespace EventStore.Client { + /// + /// Exception thrown if the expected version specified on an operation + /// does not match the version of the stream when the operation was attempted. + /// + public class WrongExpectedVersionException : Exception { + /// + /// The stream identifier. + /// + public string StreamName { get; } + + /// + /// If available, the expected version specified for the operation that failed. + /// + public long? ExpectedVersion { get; } + + /// + /// If available, the current version of the stream that the operation was attempted on. + /// + public long? ActualVersion { get; } + + /// + /// The current of the stream that the operation was attempted on. + /// + public StreamRevision ActualStreamRevision { get; } + + /// + /// If available, the expected version specified for the operation that failed. + /// + public StreamRevision ExpectedStreamRevision { get; } + + /// + /// Constructs a new instance of with the expected and actual versions if available. + /// + public WrongExpectedVersionException(string streamName, StreamRevision expectedStreamRevision, + StreamRevision actualStreamRevision, Exception? exception = null, string? message = null) : + base( + message ?? $"Append failed due to WrongExpectedVersion. Stream: {streamName}, Expected version: {expectedStreamRevision}, Actual version: {actualStreamRevision}", + exception) { + StreamName = streamName; + ActualStreamRevision = actualStreamRevision; + ExpectedStreamRevision = expectedStreamRevision; + ExpectedVersion = expectedStreamRevision == StreamRevision.None ? new long?() : expectedStreamRevision.ToInt64(); + ActualVersion = actualStreamRevision == StreamRevision.None ? new long?() : actualStreamRevision.ToInt64(); + } + + /// + /// Constructs a new instance of with the expected and actual versions if available. + /// + /// + /// + /// + /// + public WrongExpectedVersionException(string streamName, StreamState expectedStreamState, + StreamRevision actualStreamRevision, Exception? exception = null) : base( + $"Append failed due to WrongExpectedVersion. Stream: {streamName}, Expected state: {expectedStreamState}, Actual version: {actualStreamRevision}", + exception) { + StreamName = streamName; + ActualStreamRevision = actualStreamRevision; + ActualVersion = actualStreamRevision == StreamRevision.None ? new long?() : actualStreamRevision.ToInt64(); + ExpectedStreamRevision = StreamRevision.None; + } + } +} diff --git a/src/Kurrent.Client/Core/FromAll.cs b/src/Kurrent.Client/Core/FromAll.cs new file mode 100644 index 000000000..3be460659 --- /dev/null +++ b/src/Kurrent.Client/Core/FromAll.cs @@ -0,0 +1,100 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure representing the logical position of a subscription to all. /> + /// + public readonly struct FromAll : IEquatable, IComparable, IComparable { + /// + /// Represents a when no events have been seen (i.e., the beginning). + /// + public static readonly FromAll Start = new(null); + + /// + /// Represents a to receive events written after the subscription is confirmed. + /// + public static readonly FromAll End = new(Position.End); + + /// + /// Returns a for the given . + /// + /// The . + /// + /// + public static FromAll After(Position position) => position == Position.End + ? throw new ArgumentException($"Use '{nameof(FromAll)}.{nameof(End)}.'", nameof(position)) + : new(position); + + private readonly Position? _value; + + private FromAll(Position? value) => _value = value; + + /// + /// Converts the to a . + /// It is not meant to be used directly from your code. + /// + /// + /// + public (ulong commitPosition, ulong preparePosition) ToUInt64() => this == Start + ? throw new InvalidOperationException( + $"{nameof(FromAll)}.{nameof(Start)} may not be converted.") + : (_value!.Value.CommitPosition, _value!.Value.PreparePosition); + + /// + public bool Equals(FromAll other) => Nullable.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is FromAll other && Equals(other); + + /// + public override int GetHashCode() => _value.GetHashCode(); + +#pragma warning disable CS1591 + public static bool operator ==(FromAll left, FromAll right) => + Nullable.Equals(left, right); + + public static bool operator !=(FromAll left, FromAll right) => + !Nullable.Equals(left, right); + + public static bool operator >(FromAll left, FromAll right) => + left.CompareTo(right) > 0; + + public static bool operator <(FromAll left, FromAll right) => + left.CompareTo(right) < 0; + + public static bool operator >=(FromAll left, FromAll right) => + left.CompareTo(right) >= 0; + + public static bool operator <=(FromAll left, FromAll right) => + left.CompareTo(right) <= 0; +#pragma warning restore CS1591 + + /// + public int CompareTo(FromAll other) => (_value, other._value) switch { + (null, null) => 0, + (null, _) => -1, + (_, null) => 1, + _ => _value.Value.CompareTo(other._value.Value) + }; + + /// + public int CompareTo(object? obj) => obj switch { + null => 1, + FromAll other => CompareTo(other), + _ => throw new ArgumentException($"Object is not a {nameof(FromAll)}"), + }; + + /// + public override string ToString() { + if (_value is null) { + return "Start"; + } + + if (_value == Position.End) { + return "Live"; + } + + return _value.Value.ToString(); + } + } +} diff --git a/src/Kurrent.Client/Core/FromStream.cs b/src/Kurrent.Client/Core/FromStream.cs new file mode 100644 index 000000000..7cf99e081 --- /dev/null +++ b/src/Kurrent.Client/Core/FromStream.cs @@ -0,0 +1,100 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure representing the logical position of a subscription. /> + /// + public readonly struct FromStream : IEquatable, IComparable, IComparable { + /// + /// Represents a when no events have been seen (i.e., the beginning). + /// + public static readonly FromStream Start = new(null); + + /// + /// Represents a to receive events written after the subscription is confirmed. + /// + public static readonly FromStream End = new(StreamPosition.End); + + private readonly StreamPosition? _value; + + /// + /// Returns a for the given . + /// + /// The . + /// + /// + public static FromStream After(StreamPosition streamPosition) => + streamPosition == StreamPosition.End + ? throw new ArgumentException($"Use '{nameof(FromStream)}.{nameof(End)}.'", nameof(streamPosition)) + : new(streamPosition); + + private FromStream(StreamPosition? value) => _value = value; + + /// + /// Converts the to a . It is not meant to be used directly from your code. + /// + /// + /// + public ulong ToUInt64() => this == Start + ? throw new InvalidOperationException( + $"{nameof(FromStream)}.{nameof(Start)} may not be converted.") + : _value!.Value.ToUInt64(); + + /// + public bool Equals(FromStream other) => Nullable.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is FromStream other && Equals(other); + + /// + public override int GetHashCode() => _value.GetHashCode(); + +#pragma warning disable CS1591 + public static bool operator ==(FromStream left, FromStream right) => + left.Equals(right); + + public static bool operator !=(FromStream left, FromStream right) => + !left.Equals(right); + + public static bool operator <(FromStream left, FromStream right) => + left.CompareTo(right) < 0; + + public static bool operator >(FromStream left, FromStream right) => + left.CompareTo(right) > 0; + + public static bool operator <=(FromStream left, FromStream right) => + left.CompareTo(right) <= 0; + + public static bool operator >=(FromStream left, FromStream right) => + left.CompareTo(right) >= 0; +#pragma warning restore CS1591 + + /// + public int CompareTo(FromStream other) => (_value, other._value) switch { + (null, null) => 0, + (null, _) => -1, + (_, null) => 1, + _ => _value.Value.CompareTo(other._value.Value) + }; + + /// + public int CompareTo(object? obj) => obj switch { + null => 1, + FromStream other => CompareTo(other), + _ => throw new ArgumentException($"Object is not a {nameof(FromStream)}"), + }; + + /// + public override string ToString() { + if (_value is null) { + return "Start"; + } + + if (_value == StreamPosition.End) { + return "Live"; + } + + return _value.Value.ToString(); + } + } +} diff --git a/src/Kurrent.Client/Core/GossipChannelSelector.cs b/src/Kurrent.Client/Core/GossipChannelSelector.cs new file mode 100644 index 000000000..5471120b6 --- /dev/null +++ b/src/Kurrent.Client/Core/GossipChannelSelector.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EventStore.Client { + // Thread safe + internal class GossipChannelSelector : IChannelSelector { + private readonly KurrentClientSettings _settings; + private readonly ChannelCache _channels; + private readonly IGossipClient _gossipClient; + private readonly ILogger _log; + private readonly NodeSelector _nodeSelector; + + public GossipChannelSelector( + KurrentClientSettings settings, + ChannelCache channelCache, + IGossipClient gossipClient) { + + _settings = settings; + _channels = channelCache; + _gossipClient = gossipClient; + _log = settings.LoggerFactory?.CreateLogger() ?? + new NullLogger(); + _nodeSelector = new(_settings); + } + + public ChannelBase SelectChannel(DnsEndPoint endPoint) { + return _channels.GetChannelInfo(endPoint); + } + + public async Task SelectChannelAsync(CancellationToken cancellationToken) { + var endPoint = await DiscoverAsync(cancellationToken).ConfigureAwait(false); + + _log.LogInformation("Successfully discovered candidate at {endPoint}.", endPoint); + + return _channels.GetChannelInfo(endPoint); + } + + private async Task DiscoverAsync(CancellationToken cancellationToken) { + for (var attempt = 1; attempt <= _settings.ConnectivitySettings.MaxDiscoverAttempts; attempt++) { + foreach (var kvp in _channels.GetRandomOrderSnapshot()) { + var endPointToGetGossip = kvp.Key; + var channelToGetGossip = kvp.Value; + + try { + var clusterInfo = await _gossipClient + .GetAsync(channelToGetGossip, cancellationToken) + .ConfigureAwait(false); + + var selectedEndpoint = _nodeSelector.SelectNode(clusterInfo); + + // Successfully selected an endpoint using this gossip! + // We want _channels to contain exactly the nodes in ClusterInfo. + // nodes no longer in the cluster can be forgotten. + // new nodes are added so we can use them to get gossip. + _channels.UpdateCache(clusterInfo.Members.Select(x => x.EndPoint)); + + return selectedEndpoint; + + } catch (Exception ex) { + _log.Log( + GetLogLevelForDiscoveryAttempt(attempt), + ex, + "Could not discover candidate from {endPoint}. Attempts remaining: {remainingAttempts}", + endPointToGetGossip, + _settings.ConnectivitySettings.MaxDiscoverAttempts - attempt); + } + } + + // couldn't select a node from any _channel. reseed the channels. + _channels.UpdateCache(_settings.ConnectivitySettings.GossipSeeds.Select(endPoint => + endPoint as DnsEndPoint ?? new DnsEndPoint(endPoint.GetHost(), endPoint.GetPort()))); + + await Task + .Delay(_settings.ConnectivitySettings.DiscoveryInterval, cancellationToken) + .ConfigureAwait(false); + } + + _log.LogError("Failed to discover candidate in {maxDiscoverAttempts} attempts.", + _settings.ConnectivitySettings.MaxDiscoverAttempts); + + throw new DiscoveryException(_settings.ConnectivitySettings.MaxDiscoverAttempts); + } + + private LogLevel GetLogLevelForDiscoveryAttempt(int attempt) => attempt switch { + _ when attempt == _settings.ConnectivitySettings.MaxDiscoverAttempts => + LogLevel.Error, + 1 => + LogLevel.Warning, + _ => + LogLevel.Debug + }; + } +} diff --git a/src/Kurrent.Client/Core/GrpcGossipClient.cs b/src/Kurrent.Client/Core/GrpcGossipClient.cs new file mode 100644 index 000000000..5291bfe6c --- /dev/null +++ b/src/Kurrent.Client/Core/GrpcGossipClient.cs @@ -0,0 +1,30 @@ +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; + +namespace EventStore.Client { + internal class GrpcGossipClient : IGossipClient { + private readonly KurrentClientSettings _settings; + + public GrpcGossipClient(KurrentClientSettings settings) { + _settings = settings; + } + + public async ValueTask GetAsync(ChannelBase channel, CancellationToken ct) { + var client = new Gossip.Gossip.GossipClient(channel); + using var call = client.ReadAsync( + new Empty(), + KurrentCallOptions.CreateNonStreaming(_settings, ct)); + var result = await call.ResponseAsync.ConfigureAwait(false); + + return new(result.Members.Select(x => + new ClusterMessages.MemberInfo( + Uuid.FromDto(x.InstanceId), + (ClusterMessages.VNodeState)x.State, + x.IsAlive, + new DnsEndPoint(x.HttpEndPoint.Address, (int)x.HttpEndPoint.Port))).ToArray()); + } + } +} diff --git a/src/Kurrent.Client/Core/GrpcServerCapabilitiesClient.cs b/src/Kurrent.Client/Core/GrpcServerCapabilitiesClient.cs new file mode 100644 index 000000000..1dc29dce0 --- /dev/null +++ b/src/Kurrent.Client/Core/GrpcServerCapabilitiesClient.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; + +namespace EventStore.Client { + internal class GrpcServerCapabilitiesClient : IServerCapabilitiesClient { + private readonly KurrentClientSettings _settings; + + public GrpcServerCapabilitiesClient(KurrentClientSettings settings) { + _settings = settings; + } + + public async Task GetAsync( + CallInvoker callInvoker, + CancellationToken cancellationToken) { + + var client = new ServerFeatures.ServerFeatures.ServerFeaturesClient(callInvoker); + using var call = client.GetSupportedMethodsAsync( + new(), + KurrentCallOptions.CreateNonStreaming( + _settings, + _settings.ConnectivitySettings.GossipTimeout, + null, + cancellationToken)); + + try { + var supportsBatchAppend = false; + var supportsPersistentSubscriptionsToAll = false; + var supportsPersistentSubscriptionsGetInfo = false; + var supportsPersistentSubscriptionsRestartSubsystem = false; + var supportsPersistentSubscriptionsReplayParked = false; + var supportsPersistentSubscriptionsList = false; + + var response = await call.ResponseAsync.ConfigureAwait(false); + + foreach (var supportedMethod in response.Methods) { + switch (supportedMethod.ServiceName, supportedMethod.MethodName) { + case ("event_store.client.streams.streams", "batchappend"): + supportsBatchAppend = true; + continue; + case ("event_store.client.persistent_subscriptions.persistentsubscriptions", "read"): + supportsPersistentSubscriptionsToAll = supportedMethod.Features.Contains("all"); + continue; + case ("event_store.client.persistent_subscriptions.persistentsubscriptions", "getinfo"): + supportsPersistentSubscriptionsGetInfo = true; + continue; + case ("event_store.client.persistent_subscriptions.persistentsubscriptions", "restartsubsystem"): + supportsPersistentSubscriptionsRestartSubsystem = true; + continue; + case ("event_store.client.persistent_subscriptions.persistentsubscriptions", "replayparked"): + supportsPersistentSubscriptionsReplayParked = true; + continue; + case ("event_store.client.persistent_subscriptions.persistentsubscriptions", "list"): + supportsPersistentSubscriptionsList = true; + continue; + } + } + + return new( + SupportsBatchAppend: supportsBatchAppend, + SupportsPersistentSubscriptionsToAll: supportsPersistentSubscriptionsToAll, + SupportsPersistentSubscriptionsGetInfo: supportsPersistentSubscriptionsGetInfo, + SupportsPersistentSubscriptionsRestartSubsystem: supportsPersistentSubscriptionsRestartSubsystem, + SupportsPersistentSubscriptionsReplayParked: supportsPersistentSubscriptionsReplayParked, + SupportsPersistentSubscriptionsList: supportsPersistentSubscriptionsList); + + } catch (Exception ex) when (ex.GetBaseException() is RpcException rpcException && + rpcException.StatusCode == StatusCode.Unimplemented) { + + return new(); + } + } + } +} diff --git a/src/Kurrent.Client/Core/HashCode.cs b/src/Kurrent.Client/Core/HashCode.cs new file mode 100644 index 000000000..31f9c2514 --- /dev/null +++ b/src/Kurrent.Client/Core/HashCode.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; + +namespace EventStore.Client { + #pragma warning disable 1591 + public readonly struct HashCode { + private readonly int _value; + + private HashCode(int value) { + _value = value; + } + + public static readonly HashCode Hash = default; + + public HashCode Combine(T? value) where T : struct => Combine(value ?? default); + + public HashCode Combine(T value) where T: struct { + unchecked { + return new HashCode((_value * 397) ^ value.GetHashCode()); + } + } + + public HashCode Combine(string? value){ + unchecked { + return new HashCode((_value * 397) ^ (value?.GetHashCode() ?? 0)); + } + } + + public HashCode Combine(IEnumerable? values) where T: struct => + (values ?? Enumerable.Empty()).Aggregate(Hash, (previous, value) => previous.Combine(value)); + + public HashCode Combine(IEnumerable? values) => + (values ?? Enumerable.Empty()).Aggregate(Hash, (previous, value) => previous.Combine(value)); + + public static implicit operator int(HashCode value) => value._value; + } +} diff --git a/src/Kurrent.Client/Core/HttpFallback.cs b/src/Kurrent.Client/Core/HttpFallback.cs new file mode 100644 index 000000000..0f2bfe02d --- /dev/null +++ b/src/Kurrent.Client/Core/HttpFallback.cs @@ -0,0 +1,143 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace EventStore.Client { + internal class HttpFallback : IDisposable { + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonSettings; + private readonly UserCredentials? _defaultCredentials; + private readonly string _addressScheme; + + internal HttpFallback(KurrentClientSettings settings) { + _addressScheme = settings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme; + _defaultCredentials = settings.DefaultCredentials; + + var handler = new HttpClientHandler(); + if (!settings.ConnectivitySettings.Insecure) { + handler.ClientCertificateOptions = ClientCertificateOption.Manual; + + if (settings.ConnectivitySettings.ClientCertificate is not null) + handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); + + handler.ServerCertificateCustomValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (certificate is null || chain is null) return false; + + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + +#if NET48 + chain.ChainPolicy.ExtraStore.Add(settings.ConnectivitySettings.TlsCaFile); +#else + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(settings.ConnectivitySettings.TlsCaFile); +#endif + + return chain.Build(certificate); + }, + _ => null + }; + } + + _httpClient = new HttpClient(handler); + if (settings.DefaultDeadline.HasValue) { + _httpClient.Timeout = settings.DefaultDeadline.Value; + } + + _jsonSettings = new JsonSerializerOptions { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + internal async Task HttpGetAsync(string path, ChannelInfo channelInfo, TimeSpan? deadline, + UserCredentials? userCredentials, Action onNotFound, CancellationToken cancellationToken) { + + var request = CreateRequest(path, HttpMethod.Get, channelInfo, userCredentials); + + var httpResult = await HttpSendAsync(request, onNotFound, deadline, cancellationToken).ConfigureAwait(false); + +#if NET + var json = await httpResult.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var json = await httpResult.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + var result = JsonSerializer.Deserialize(json, _jsonSettings); + if (result == null) { + throw new InvalidOperationException("Unable to deserialize response into object of type " + typeof(T)); + } + + return result; + } + + internal async Task HttpPostAsync(string path, string query, ChannelInfo channelInfo, TimeSpan? deadline, + UserCredentials? userCredentials, Action onNotFound, CancellationToken cancellationToken) { + + var request = CreateRequest(path, query, HttpMethod.Post, channelInfo, userCredentials); + + await HttpSendAsync(request, onNotFound, deadline, cancellationToken).ConfigureAwait(false); + } + + private async Task HttpSendAsync(HttpRequestMessage request, Action onNotFound, + TimeSpan? deadline, CancellationToken cancellationToken) { + + if (!deadline.HasValue) { + return await HttpSendAsync(request, onNotFound, cancellationToken).ConfigureAwait(false); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(deadline.Value); + + return await HttpSendAsync(request, onNotFound, cts.Token).ConfigureAwait(false); + } + + async Task HttpSendAsync(HttpRequestMessage request, Action onNotFound, + CancellationToken cancellationToken) { + + var httpResult = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (httpResult.IsSuccessStatusCode) { + return httpResult; + } + + if (httpResult.StatusCode == HttpStatusCode.Unauthorized) { + throw new AccessDeniedException(); + } + + if (httpResult.StatusCode == HttpStatusCode.NotFound) { + onNotFound(); + } + + throw new Exception($"The HTTP request failed with status code: {httpResult.StatusCode}"); + } + + private HttpRequestMessage CreateRequest(string path, HttpMethod method, ChannelInfo channelInfo, + UserCredentials? credentials) => CreateRequest(path, query: "", method, channelInfo, credentials); + + private HttpRequestMessage CreateRequest(string path, string query, HttpMethod method, ChannelInfo channelInfo, + UserCredentials? credentials) { + + var uriBuilder = new UriBuilder($"{_addressScheme}://{channelInfo.Channel.Target}") { + Path = path, + Query = query + }; + + var httpRequest = new HttpRequestMessage(method, uriBuilder.Uri); + httpRequest.Headers.Add("accept", "application/json"); + credentials ??= _defaultCredentials; + if (credentials != null) { + httpRequest.Headers.Add(Constants.Headers.Authorization, credentials.ToString()); + } + + return httpRequest; + } + + public void Dispose() { + _httpClient.Dispose(); + } + } +} diff --git a/src/Kurrent.Client/Core/IChannelSelector.cs b/src/Kurrent.Client/Core/IChannelSelector.cs new file mode 100644 index 000000000..1765ccebc --- /dev/null +++ b/src/Kurrent.Client/Core/IChannelSelector.cs @@ -0,0 +1,14 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; + +namespace EventStore.Client { + internal interface IChannelSelector { + // Let the channel selector pick an endpoint. + Task SelectChannelAsync(CancellationToken cancellationToken); + + // Get a channel for the specified endpoint + ChannelBase SelectChannel(DnsEndPoint endPoint); + } +} diff --git a/src/Kurrent.Client/Core/IEventFilter.cs b/src/Kurrent.Client/Core/IEventFilter.cs new file mode 100644 index 000000000..eb290685e --- /dev/null +++ b/src/Kurrent.Client/Core/IEventFilter.cs @@ -0,0 +1,21 @@ +namespace EventStore.Client { + /// + /// An interface that represents a search filter, used for read operations. + /// + public interface IEventFilter { + /// + /// The s associated with this . + /// + PrefixFilterExpression[] Prefixes { get; } + + /// + /// The associated with this . + /// + RegularFilterExpression Regex { get; } + + /// + /// The maximum number of events to read that do not match the filter before the operation returns. + /// + uint? MaxSearchWindow { get; } + } +} diff --git a/src/Kurrent.Client/Core/IGossipClient.cs b/src/Kurrent.Client/Core/IGossipClient.cs new file mode 100644 index 000000000..80c0e9395 --- /dev/null +++ b/src/Kurrent.Client/Core/IGossipClient.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; + +namespace EventStore.Client { + internal interface IGossipClient { + public ValueTask GetAsync(ChannelBase channel, + CancellationToken cancellationToken); + } +} diff --git a/src/Kurrent.Client/Core/IPosition.cs b/src/Kurrent.Client/Core/IPosition.cs new file mode 100644 index 000000000..e3c5da9bc --- /dev/null +++ b/src/Kurrent.Client/Core/IPosition.cs @@ -0,0 +1,7 @@ +namespace EventStore.Client { + /// + /// Represents the position in a stream or transaction file + /// + public interface IPosition { + } +} diff --git a/src/Kurrent.Client/Core/IServerCapabilitiesClient.cs b/src/Kurrent.Client/Core/IServerCapabilitiesClient.cs new file mode 100644 index 000000000..20943805d --- /dev/null +++ b/src/Kurrent.Client/Core/IServerCapabilitiesClient.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; + +namespace EventStore.Client { + internal interface IServerCapabilitiesClient { + public Task GetAsync(CallInvoker callInvoker, CancellationToken cancellationToken); + } +} diff --git a/src/Kurrent.Client/Core/Interceptors/ConnectionNameInterceptor.cs b/src/Kurrent.Client/Core/Interceptors/ConnectionNameInterceptor.cs new file mode 100644 index 000000000..d709a2a3b --- /dev/null +++ b/src/Kurrent.Client/Core/Interceptors/ConnectionNameInterceptor.cs @@ -0,0 +1,45 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace EventStore.Client.Interceptors { + internal class ConnectionNameInterceptor : Interceptor { + private readonly string _connectionName; + + public ConnectionNameInterceptor(string connectionName) { + _connectionName = connectionName; + } + + public override AsyncUnaryCall AsyncUnaryCall(TRequest request, + ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation) { + AddConnectionName(context); + return continuation(request, context); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + ClientInterceptorContext context, + AsyncClientStreamingCallContinuation continuation) { + AddConnectionName(context); + return continuation(context); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + TRequest request, + ClientInterceptorContext context, + AsyncServerStreamingCallContinuation continuation) { + AddConnectionName(context); + return continuation(request, context); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + ClientInterceptorContext context, + AsyncDuplexStreamingCallContinuation continuation) { + AddConnectionName(context); + return continuation(context); + } + + private void AddConnectionName(ClientInterceptorContext context) + where TRequest : class where TResponse : class => + context.Options.Headers?.Add(Constants.Headers.ConnectionName, _connectionName); + } +} diff --git a/src/Kurrent.Client/Core/Interceptors/ReportLeaderInterceptor.cs b/src/Kurrent.Client/Core/Interceptors/ReportLeaderInterceptor.cs new file mode 100644 index 000000000..6d9327858 --- /dev/null +++ b/src/Kurrent.Client/Core/Interceptors/ReportLeaderInterceptor.cs @@ -0,0 +1,126 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace EventStore.Client.Interceptors { + // this has become more general than just detecting leader changes. + // triggers the action on any rpc exception with StatusCode.Unavailable + internal class ReportLeaderInterceptor : Interceptor { + private readonly Action _onReconnectionRequired; + + private const TaskContinuationOptions ContinuationOptions = + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted; + + internal ReportLeaderInterceptor(Action onReconnectionRequired) { + _onReconnectionRequired = onReconnectionRequired; + } + + public override AsyncUnaryCall AsyncUnaryCall(TRequest request, + ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation) { + var response = continuation(request, context); + + response.ResponseAsync.ContinueWith(OnReconnectionRequired, ContinuationOptions); + + return new AsyncUnaryCall(response.ResponseAsync, response.ResponseHeadersAsync, + response.GetStatus, response.GetTrailers, response.Dispose); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + ClientInterceptorContext context, + AsyncClientStreamingCallContinuation continuation) { + var response = continuation(context); + + response.ResponseAsync.ContinueWith(OnReconnectionRequired, ContinuationOptions); + + return new AsyncClientStreamingCall( + new StreamWriter(response.RequestStream, OnReconnectionRequired), + response.ResponseAsync, + response.ResponseHeadersAsync, response.GetStatus, response.GetTrailers, response.Dispose); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + ClientInterceptorContext context, + AsyncDuplexStreamingCallContinuation continuation) { + var response = continuation(context); + + return new AsyncDuplexStreamingCall( + new StreamWriter(response.RequestStream, OnReconnectionRequired), + new StreamReader(response.ResponseStream, OnReconnectionRequired), + response.ResponseHeadersAsync, + response.GetStatus, response.GetTrailers, response.Dispose); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + TRequest request, ClientInterceptorContext context, + AsyncServerStreamingCallContinuation continuation) { + var response = continuation(request, context); + + return new AsyncServerStreamingCall( + new StreamReader(response.ResponseStream, OnReconnectionRequired), + response.ResponseHeadersAsync, + response.GetStatus, response.GetTrailers, response.Dispose); + } + + private void OnReconnectionRequired(Task task) { + ReconnectionRequired reconnectionRequired = task.Exception?.InnerException switch { + NotLeaderException ex => new ReconnectionRequired.NewLeader(ex.LeaderEndpoint), + RpcException { + StatusCode: StatusCode.Unavailable + // or StatusCode.Unknown or TODO: use RPC exceptions on server + } => ReconnectionRequired.Rediscover.Instance, + _ => ReconnectionRequired.None.Instance + }; + + if (reconnectionRequired is not ReconnectionRequired.None) + _onReconnectionRequired(reconnectionRequired); + } + + private class StreamWriter : IClientStreamWriter { + private readonly IClientStreamWriter _inner; + private readonly Action _reportNewLeader; + + public StreamWriter(IClientStreamWriter inner, Action reportNewLeader) { + _inner = inner; + _reportNewLeader = reportNewLeader; + } + + public WriteOptions? WriteOptions { + get => _inner.WriteOptions; + set => _inner.WriteOptions = value; + } + + public Task CompleteAsync() { + var task = _inner.CompleteAsync(); + task.ContinueWith(_reportNewLeader, ContinuationOptions); + return task; + } + + public Task WriteAsync(T message) { + var task = _inner.WriteAsync(message); + task.ContinueWith(_reportNewLeader, ContinuationOptions); + return task; + } + } + + private class StreamReader : IAsyncStreamReader { + private readonly IAsyncStreamReader _inner; + private readonly Action _reportNewLeader; + + public StreamReader(IAsyncStreamReader inner, Action reportNewLeader) { + _inner = inner; + _reportNewLeader = reportNewLeader; + } + + public Task MoveNext(CancellationToken cancellationToken) { + var task = _inner.MoveNext(cancellationToken); + task.ContinueWith(_reportNewLeader, ContinuationOptions); + return task; + } + + public T Current => _inner.Current; + } + } +} diff --git a/src/Kurrent.Client/Core/Interceptors/TypedExceptionInterceptor.cs b/src/Kurrent.Client/Core/Interceptors/TypedExceptionInterceptor.cs new file mode 100644 index 000000000..0f1368805 --- /dev/null +++ b/src/Kurrent.Client/Core/Interceptors/TypedExceptionInterceptor.cs @@ -0,0 +1,165 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using static EventStore.Client.Constants; +using static Grpc.Core.StatusCode; + +namespace EventStore.Client.Interceptors; + +class TypedExceptionInterceptor : Interceptor { + static readonly Dictionary> DefaultExceptionMap = new() { + [Exceptions.AccessDenied] = ex => ex.ToAccessDeniedException(), + [Exceptions.NotLeader] = ex => ex.ToNotLeaderException(), + }; + + public TypedExceptionInterceptor(Dictionary> customExceptionMap) { +#if NET48 + var map = new Dictionary>(DefaultExceptionMap.Concat(customExceptionMap).ToDictionary(x => x.Key, x => x.Value)); +#else + var map = new Dictionary>(DefaultExceptionMap.Concat(customExceptionMap)); +#endif + ConvertRpcException = rpcEx => { + if (rpcEx.TryMapException(map, out var ex)) + throw ex; + + throw rpcEx.StatusCode switch { + Unavailable when rpcEx.Status.Detail == "Deadline Exceeded" => rpcEx.ToDeadlineExceededRpcException(), + Unauthenticated => rpcEx.ToNotAuthenticatedException(), + _ => rpcEx + }; + }; + } + + Func ConvertRpcException { get; } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + TRequest request, + ClientInterceptorContext context, + AsyncServerStreamingCallContinuation continuation + ) { + var response = continuation(request, context); + + return new AsyncServerStreamingCall( + response.ResponseStream.Apply(ConvertRpcException), + response.ResponseHeadersAsync, + response.GetStatus, + response.GetTrailers, + response.Dispose + ); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + ClientInterceptorContext context, + AsyncClientStreamingCallContinuation continuation + ) { + var response = continuation(context); + + return new AsyncClientStreamingCall( + response.RequestStream.Apply(ConvertRpcException), + response.ResponseAsync.Apply(ConvertRpcException), + response.ResponseHeadersAsync, + response.GetStatus, + response.GetTrailers, + response.Dispose + ); + } + + public override AsyncUnaryCall AsyncUnaryCall( + TRequest request, + ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation + ) { + var response = continuation(request, context); + + return new AsyncUnaryCall( + response.ResponseAsync.Apply(ConvertRpcException), + response.ResponseHeadersAsync, + response.GetStatus, + response.GetTrailers, + response.Dispose + ); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + ClientInterceptorContext context, + AsyncDuplexStreamingCallContinuation continuation + ) { + var response = continuation(context); + + return new AsyncDuplexStreamingCall( + response.RequestStream, + response.ResponseStream.Apply(ConvertRpcException), + response.ResponseHeadersAsync, + response.GetStatus, + response.GetTrailers, + response.Dispose + ); + } +} + +static class RpcExceptionConversionExtensions { + public static IAsyncStreamReader Apply(this IAsyncStreamReader reader, Func convertException) => + new ExceptionConverterStreamReader(reader, convertException); + + public static Task Apply(this Task task, Func convertException) => + task.ContinueWith(t => t.Exception?.InnerException is RpcException ex ? throw convertException(ex) : t.Result); + + public static IClientStreamWriter Apply( + this IClientStreamWriter writer, Func convertException + ) => + new ExceptionConverterStreamWriter(writer, convertException); + + public static Task Apply(this Task task, Func convertException) => + task.ContinueWith(t => t.Exception?.InnerException is RpcException ex ? throw convertException(ex) : t); + + public static AccessDeniedException ToAccessDeniedException(this RpcException exception) => + new(exception.Message, exception); + + public static NotLeaderException ToNotLeaderException(this RpcException exception) { + var host = exception.Trailers.FirstOrDefault(x => x.Key == Exceptions.LeaderEndpointHost)?.Value!; + var port = exception.Trailers.GetIntValueOrDefault(Exceptions.LeaderEndpointPort); + return new NotLeaderException(host, port, exception); + } + + public static NotAuthenticatedException ToNotAuthenticatedException(this RpcException exception) => + new(exception.Message, exception); + + public static RpcException ToDeadlineExceededRpcException(this RpcException exception) => + new(new Status(DeadlineExceeded, exception.Status.Detail, exception.Status.DebugException)); + + public static bool TryMapException(this RpcException exception, Dictionary> map, out Exception createdException) { + if (exception.Trailers.TryGetValue(Exceptions.ExceptionKey, out var key) && map.TryGetValue(key!, out var factory)) { + createdException = factory.Invoke(exception); + return true; + } + + createdException = null!; + return false; + } +} + +class ExceptionConverterStreamReader(IAsyncStreamReader reader, Func convertException) : IAsyncStreamReader { + public TResponse Current => reader.Current; + + public async Task MoveNext(CancellationToken cancellationToken) { + try { + return await reader.MoveNext(cancellationToken).ConfigureAwait(false); + } + catch (RpcException ex) { + throw convertException(ex); + } + } +} + +class ExceptionConverterStreamWriter( + IClientStreamWriter writer, + Func convertException +) + : IClientStreamWriter { + public WriteOptions? WriteOptions { + get => writer.WriteOptions; + set => writer.WriteOptions = value; + } + + public Task WriteAsync(TRequest message) => writer.WriteAsync(message).Apply(convertException); + public Task CompleteAsync() => writer.CompleteAsync().Apply(convertException); +} diff --git a/src/Kurrent.Client/Core/KurrentClientBase.cs b/src/Kurrent.Client/Core/KurrentClientBase.cs new file mode 100644 index 000000000..923c6f334 --- /dev/null +++ b/src/Kurrent.Client/Core/KurrentClientBase.cs @@ -0,0 +1,151 @@ +using EventStore.Client.Interceptors; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Enum = System.Enum; + +namespace EventStore.Client { + /// + /// The base class used by clients used to communicate with the KurrentDB. + /// + public abstract class KurrentClientBase : + IDisposable, // for grpc.net we can dispose synchronously, but not for grpc.core + IAsyncDisposable { + private readonly Dictionary> _exceptionMap; + private readonly CancellationTokenSource _cts; + private readonly ChannelCache _channelCache; + private readonly SharingProvider _channelInfoProvider; + private readonly Lazy _httpFallback; + + /// The name of the connection. + public string ConnectionName { get; } + + /// The . + protected KurrentClientSettings Settings { get; } + + /// Constructs a new . + protected KurrentClientBase( + KurrentClientSettings? settings, + Dictionary> exceptionMap + ) { + Settings = settings ?? new KurrentClientSettings(); + _exceptionMap = exceptionMap; + _cts = new CancellationTokenSource(); + _channelCache = new(Settings); + _httpFallback = new Lazy(() => new HttpFallback(Settings)); + + ConnectionName = Settings.ConnectionName ?? $"ES-{Guid.NewGuid()}"; + + var channelSelector = new ChannelSelector(Settings, _channelCache); + _channelInfoProvider = new SharingProvider( + factory: (endPoint, onBroken) => + GetChannelInfoExpensive(endPoint, onBroken, channelSelector, _cts.Token), + factoryRetryDelay: Settings.ConnectivitySettings.DiscoveryInterval, + initialInput: ReconnectionRequired.Rediscover.Instance, + loggerFactory: Settings.LoggerFactory + ); + } + + // Select a channel and query its capabilities. This is an expensive call that + // we don't want to do often. + private async Task GetChannelInfoExpensive( + ReconnectionRequired reconnectionRequired, + Action onReconnectionRequired, + IChannelSelector channelSelector, + CancellationToken cancellationToken + ) { + var channel = reconnectionRequired switch { + ReconnectionRequired.Rediscover => await channelSelector.SelectChannelAsync(cancellationToken) + .ConfigureAwait(false), + ReconnectionRequired.NewLeader (var endPoint) => channelSelector.SelectChannel(endPoint), + _ => throw new ArgumentException(null, nameof(reconnectionRequired)) + }; + + var invoker = channel.CreateCallInvoker() + .Intercept(new TypedExceptionInterceptor(_exceptionMap)) + .Intercept(new ConnectionNameInterceptor(ConnectionName)) + .Intercept(new ReportLeaderInterceptor(onReconnectionRequired)); + + if (Settings.Interceptors is not null) { + foreach (var interceptor in Settings.Interceptors) { + invoker = invoker.Intercept(interceptor); + } + } + + var caps = await new GrpcServerCapabilitiesClient(Settings) + .GetAsync(invoker, cancellationToken) + .ConfigureAwait(false); + + return new(channel, caps, invoker); + } + + /// Gets the current channel info. + protected async ValueTask GetChannelInfo(CancellationToken cancellationToken) => + await _channelInfoProvider.CurrentAsync.WithCancellation(cancellationToken).ConfigureAwait(false); + + /// + /// Only exists so that we can manually trigger rediscovery in the tests + /// in cases where the server doesn't yet let the client know that it needs to. + /// note if rediscovery is already in progress it will continue, not restart. + /// + internal Task RediscoverAsync() { + _channelInfoProvider.Reset(); + return Task.CompletedTask; + } + + /// Returns the result of an HTTP Get request based on the client settings. + protected async Task HttpGet( + string path, Action onNotFound, ChannelInfo channelInfo, + TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken + ) { + return await _httpFallback.Value + .HttpGetAsync(path, channelInfo, deadline, userCredentials, onNotFound, cancellationToken) + .ConfigureAwait(false); + } + + /// Executes an HTTP Post request based on the client settings. + protected async Task HttpPost( + string path, string query, Action onNotFound, ChannelInfo channelInfo, + TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken + ) { + await _httpFallback.Value + .HttpPostAsync( + path, + query, + channelInfo, + deadline, + userCredentials, + onNotFound, + cancellationToken + ) + .ConfigureAwait(false); + } + + /// + public virtual void Dispose() { + _channelInfoProvider.Dispose(); + _cts.Cancel(); + _cts.Dispose(); + _channelCache.Dispose(); + + if (_httpFallback.IsValueCreated) { + _httpFallback.Value.Dispose(); + } + } + + /// + public virtual async ValueTask DisposeAsync() { + _channelInfoProvider.Dispose(); + _cts.Cancel(); + _cts.Dispose(); + await _channelCache.DisposeAsync().ConfigureAwait(false); + + if (_httpFallback.IsValueCreated) { + _httpFallback.Value.Dispose(); + } + } + + /// Returns an InvalidOperation exception. + protected Exception InvalidOption(T option) where T : Enum => + new InvalidOperationException($"The {typeof(T).Name} {option:x} was not valid."); + } +} diff --git a/src/Kurrent.Client/Core/KurrentClientConnectivitySettings.cs b/src/Kurrent.Client/Core/KurrentClientConnectivitySettings.cs new file mode 100644 index 000000000..4ea90dcaf --- /dev/null +++ b/src/Kurrent.Client/Core/KurrentClientConnectivitySettings.cs @@ -0,0 +1,128 @@ +using System; +using System.Net; +using System.Security.Cryptography.X509Certificates; + +namespace EventStore.Client { + /// + /// A class used to describe how to connect to an instance of KurrentDB. + /// + public class KurrentClientConnectivitySettings { + private const int DefaultPort = 2113; + private bool _insecure; + private Uri? _address; + + /// + /// The of the KurrentDB. Use this when connecting to a single node. + /// + public Uri? Address { + get => IsSingleNode ? _address : null; + set => _address = value; + } + + internal Uri ResolvedAddressOrDefault => Address ?? DefaultAddress; + + private Uri DefaultAddress => + new UriBuilder { + Scheme = _insecure ? Uri.UriSchemeHttp : Uri.UriSchemeHttps, + Port = DefaultPort + }.Uri; + + /// + /// The maximum number of times to attempt discovery. + /// + public int MaxDiscoverAttempts { get; set; } + + /// + /// An array of s used to seed gossip. + /// + public EndPoint[] GossipSeeds => + ((object?)DnsGossipSeeds ?? IpGossipSeeds) switch { + DnsEndPoint[] dns => Array.ConvertAll(dns, x => x), + IPEndPoint[] ip => Array.ConvertAll(ip, x => x), + _ => Array.Empty() + }; + + /// + /// An array of s to use for seeding gossip. This will be checked before . + /// + public DnsEndPoint[]? DnsGossipSeeds { get; set; } + + /// + /// An array of s to use for seeding gossip. This will be checked after . + /// + public IPEndPoint[]? IpGossipSeeds { get; set; } + + /// + /// The after which an attempt to discover gossip will fail. + /// + public TimeSpan GossipTimeout { get; set; } + + /// + /// Whether or not to use HTTPS when communicating via gossip. + /// + [Obsolete] + public bool GossipOverHttps { get; set; } = true; + + /// + /// The polling interval used to discover the . + /// + public TimeSpan DiscoveryInterval { get; set; } + + /// + /// The to use when connecting. + /// + public NodePreference NodePreference { get; set; } + + /// + /// The optional amount of time to wait after which a keepalive ping is sent on the transport. + /// + public TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// The optional amount of time to wait after which a sent keepalive ping is considered timed out. + /// + public TimeSpan KeepAliveTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// True if pointing to a single KurrentDB node. + /// + public bool IsSingleNode => GossipSeeds.Length == 0; + + /// + /// True if communicating over an insecure channel; otherwise false. + /// + public bool Insecure { + get => IsSingleNode ? string.Equals(Address?.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) : _insecure; + set => _insecure = value; + } + + /// + /// True if certificates will be validated; otherwise false. + /// + public bool TlsVerifyCert { get; set; } = true; + + /// + /// Path to a certificate file for secure connection. Not required for enabling secure connection. Useful for self-signed certificate + /// that are not installed on the system trust store. + /// + public X509Certificate2? TlsCaFile { get; set; } + + /// + /// Client certificate used for user authentication. + /// + public X509Certificate2? ClientCertificate { get; set; } + + /// + /// The default . + /// + public static KurrentClientConnectivitySettings Default => new KurrentClientConnectivitySettings { + MaxDiscoverAttempts = 10, + GossipTimeout = TimeSpan.FromSeconds(5), + DiscoveryInterval = TimeSpan.FromMilliseconds(100), + NodePreference = NodePreference.Leader, + KeepAliveInterval = TimeSpan.FromSeconds(10), + KeepAliveTimeout = TimeSpan.FromSeconds(10), + TlsVerifyCert = true, + }; + } +} diff --git a/src/Kurrent.Client/Core/KurrentClientOperationOptions.cs b/src/Kurrent.Client/Core/KurrentClientOperationOptions.cs new file mode 100644 index 000000000..36c8d5869 --- /dev/null +++ b/src/Kurrent.Client/Core/KurrentClientOperationOptions.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace EventStore.Client { + /// + /// A class representing the options to apply to an individual operation. + /// + public class KurrentClientOperationOptions { + /// + /// Whether or not to immediately throw a when an append fails. + /// + public bool ThrowOnAppendFailure { get; set; } + + /// + /// The batch size, in bytes. + /// + public int BatchAppendSize { get; set; } + + /// + /// A callback function to extract the authorize header value from the used in the operation. + /// + public Func> GetAuthenticationHeaderValue { get; set; } = + null!; + + /// + /// The default . + /// + public static KurrentClientOperationOptions Default => new() { + ThrowOnAppendFailure = true, + GetAuthenticationHeaderValue = (userCredentials, _) => new ValueTask(userCredentials.ToString()), + BatchAppendSize = 3 * 1024 * 1024 + }; + + + /// + /// Clones a copy of the current . + /// + /// + public KurrentClientOperationOptions Clone() => new() { + ThrowOnAppendFailure = ThrowOnAppendFailure, + GetAuthenticationHeaderValue = GetAuthenticationHeaderValue, + BatchAppendSize = BatchAppendSize + }; + } +} diff --git a/src/Kurrent.Client/Core/KurrentClientSettings.ConnectionString.cs b/src/Kurrent.Client/Core/KurrentClientSettings.ConnectionString.cs new file mode 100644 index 000000000..307794019 --- /dev/null +++ b/src/Kurrent.Client/Core/KurrentClientSettings.ConnectionString.cs @@ -0,0 +1,405 @@ +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Timeout_ = System.Threading.Timeout; + +namespace EventStore.Client { + public partial class KurrentClientSettings { + /// + /// Creates client settings from a connection string + /// + /// + /// + public static KurrentClientSettings Create(string connectionString) => + ConnectionStringParser.Parse(connectionString); + + private static class ConnectionStringParser { + private const string SchemeSeparator = "://"; + private const string UserInfoSeparator = "@"; + private const string Colon = ":"; + private const string Slash = "/"; + private const string Comma = ","; + private const string Ampersand = "&"; + private const string Equal = "="; + private const string QuestionMark = "?"; + + private const string Tls = nameof(Tls); + private const string ConnectionName = nameof(ConnectionName); + private const string MaxDiscoverAttempts = nameof(MaxDiscoverAttempts); + private const string DiscoveryInterval = nameof(DiscoveryInterval); + private const string GossipTimeout = nameof(GossipTimeout); + private const string NodePreference = nameof(NodePreference); + private const string TlsVerifyCert = nameof(TlsVerifyCert); + private const string TlsCaFile = nameof(TlsCaFile); + private const string DefaultDeadline = nameof(DefaultDeadline); + private const string ThrowOnAppendFailure = nameof(ThrowOnAppendFailure); + private const string KeepAliveInterval = nameof(KeepAliveInterval); + private const string KeepAliveTimeout = nameof(KeepAliveTimeout); + private const string UserCertFile = nameof(UserCertFile); + private const string UserKeyFile = nameof(UserKeyFile); + + private const string UriSchemeDiscover = "esdb+discover"; + + private static readonly string[] Schemes = { "esdb", UriSchemeDiscover }; + private static readonly int DefaultPort = KurrentClientConnectivitySettings.Default.ResolvedAddressOrDefault.Port; + private static readonly bool DefaultUseTls = true; + + private static readonly Dictionary SettingsType = + new(StringComparer.InvariantCultureIgnoreCase) { + { ConnectionName, typeof(string) }, + { MaxDiscoverAttempts, typeof(int) }, + { DiscoveryInterval, typeof(int) }, + { GossipTimeout, typeof(int) }, + { NodePreference, typeof(string) }, + { Tls, typeof(bool) }, + { TlsVerifyCert, typeof(bool) }, + { TlsCaFile, typeof(string) }, + { DefaultDeadline, typeof(int) }, + { ThrowOnAppendFailure, typeof(bool) }, + { KeepAliveInterval, typeof(int) }, + { KeepAliveTimeout, typeof(int) }, + { UserCertFile, typeof(string) }, + { UserKeyFile, typeof(string) }, + }; + + public static KurrentClientSettings Parse(string connectionString) { + var currentIndex = 0; + var schemeIndex = connectionString.IndexOf(SchemeSeparator, currentIndex, StringComparison.Ordinal); + if (schemeIndex == -1) + throw new NoSchemeException(); + + var scheme = ParseScheme(connectionString.Substring(0, schemeIndex)); + + currentIndex = schemeIndex + SchemeSeparator.Length; + var userInfoIndex = connectionString.IndexOf(UserInfoSeparator, currentIndex, StringComparison.Ordinal); + (string user, string pass)? userInfo = null; + if (userInfoIndex != -1) { + userInfo = ParseUserInfo(connectionString.Substring(currentIndex, userInfoIndex - currentIndex)); + currentIndex = userInfoIndex + UserInfoSeparator.Length; + } + + var slashIndex = connectionString.IndexOf(Slash, currentIndex, StringComparison.Ordinal); + var questionMarkIndex = connectionString.IndexOf(QuestionMark, currentIndex, StringComparison.Ordinal); + var endIndex = connectionString.Length; + + //for simpler substring operations: + if (slashIndex == -1) slashIndex = int.MaxValue; + if (questionMarkIndex == -1) questionMarkIndex = int.MaxValue; + + var hostSeparatorIndex = Math.Min(Math.Min(slashIndex, questionMarkIndex), endIndex); + var hosts = ParseHosts(connectionString.Substring(currentIndex, hostSeparatorIndex - currentIndex)); + currentIndex = hostSeparatorIndex; + + string path = ""; + if (slashIndex != int.MaxValue) + path = connectionString.Substring( + currentIndex, + Math.Min(questionMarkIndex, endIndex) - currentIndex + ); + + if (path != "" && path != "/") + throw new ConnectionStringParseException( + $"The specified path must be either an empty string or a forward slash (/) but the following path was found instead: '{path}'" + ); + + var options = new Dictionary(); + if (questionMarkIndex != int.MaxValue) { + currentIndex = questionMarkIndex + QuestionMark.Length; + options = ParseKeyValuePairs(connectionString.Substring(currentIndex)); + } + + return CreateSettings(scheme, userInfo, hosts, options); + } + + private static KurrentClientSettings CreateSettings( + string scheme, (string user, string pass)? userInfo, + EndPoint[] hosts, Dictionary options + ) { + var settings = new KurrentClientSettings { + ConnectivitySettings = KurrentClientConnectivitySettings.Default, + OperationOptions = KurrentClientOperationOptions.Default + }; + + if (userInfo.HasValue) + settings.DefaultCredentials = new UserCredentials(userInfo.Value.user, userInfo.Value.pass); + + var typedOptions = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + foreach (var kv in options) { + if (!SettingsType.TryGetValue(kv.Key, out var type)) + throw new InvalidSettingException($"Unknown option: {kv.Key}"); + + if (type == typeof(int)) { + if (!int.TryParse(kv.Value, out var intValue)) + throw new InvalidSettingException($"{kv.Key} must be an integer value"); + + typedOptions.Add(kv.Key, intValue); + } else if (type == typeof(bool)) { + if (!bool.TryParse(kv.Value, out var boolValue)) + throw new InvalidSettingException($"{kv.Key} must be either true or false"); + + typedOptions.Add(kv.Key, boolValue); + } else if (type == typeof(string)) { + typedOptions.Add(kv.Key, kv.Value); + } + } + + if (typedOptions.TryGetValue(ConnectionName, out object? connectionName)) + settings.ConnectionName = (string)connectionName; + + if (typedOptions.TryGetValue(MaxDiscoverAttempts, out object? maxDiscoverAttempts)) + settings.ConnectivitySettings.MaxDiscoverAttempts = (int)maxDiscoverAttempts; + + if (typedOptions.TryGetValue(DiscoveryInterval, out object? discoveryInterval)) + settings.ConnectivitySettings.DiscoveryInterval = TimeSpan.FromMilliseconds((int)discoveryInterval); + + if (typedOptions.TryGetValue(GossipTimeout, out object? gossipTimeout)) + settings.ConnectivitySettings.GossipTimeout = TimeSpan.FromMilliseconds((int)gossipTimeout); + + if (typedOptions.TryGetValue(NodePreference, out object? nodePreference)) { + settings.ConnectivitySettings.NodePreference = ((string)nodePreference).ToLowerInvariant() switch { + "leader" => EventStore.Client.NodePreference.Leader, + "follower" => EventStore.Client.NodePreference.Follower, + "random" => EventStore.Client.NodePreference.Random, + "readonlyreplica" => EventStore.Client.NodePreference.ReadOnlyReplica, + _ => throw new InvalidSettingException($"Invalid NodePreference: {nodePreference}") + }; + } + + var useTls = DefaultUseTls; + if (typedOptions.TryGetValue(Tls, out object? tls)) { + useTls = (bool)tls; + } + + if (typedOptions.TryGetValue(DefaultDeadline, out object? operationTimeout)) + settings.DefaultDeadline = TimeSpan.FromMilliseconds((int)operationTimeout); + + if (typedOptions.TryGetValue(ThrowOnAppendFailure, out object? throwOnAppendFailure)) + settings.OperationOptions.ThrowOnAppendFailure = (bool)throwOnAppendFailure; + + if (typedOptions.TryGetValue(KeepAliveInterval, out var keepAliveIntervalMs)) { + settings.ConnectivitySettings.KeepAliveInterval = keepAliveIntervalMs switch { + -1 => Timeout_.InfiniteTimeSpan, + int value and >= 0 => TimeSpan.FromMilliseconds(value), + _ => throw new InvalidSettingException($"Invalid KeepAliveInterval: {keepAliveIntervalMs}") + }; + } + + if (typedOptions.TryGetValue(KeepAliveTimeout, out var keepAliveTimeoutMs)) { + settings.ConnectivitySettings.KeepAliveTimeout = keepAliveTimeoutMs switch { + -1 => Timeout_.InfiniteTimeSpan, + int value and >= 0 => TimeSpan.FromMilliseconds(value), + _ => throw new InvalidSettingException($"Invalid KeepAliveTimeout: {keepAliveTimeoutMs}") + }; + } + + settings.ConnectivitySettings.Insecure = !useTls; + + if (hosts.Length == 1 && scheme != UriSchemeDiscover) { + settings.ConnectivitySettings.Address = hosts[0].ToUri(useTls); + } else { + if (hosts.Any(x => x is DnsEndPoint)) + settings.ConnectivitySettings.DnsGossipSeeds = + Array.ConvertAll(hosts, x => new DnsEndPoint(x.GetHost(), x.GetPort())); + else + settings.ConnectivitySettings.IpGossipSeeds = Array.ConvertAll(hosts, x => (IPEndPoint)x); + } + + if (typedOptions.TryGetValue(TlsVerifyCert, out var tlsVerifyCert)) { + settings.ConnectivitySettings.TlsVerifyCert = (bool)tlsVerifyCert; + } + + if (typedOptions.TryGetValue(TlsCaFile, out var tlsCaFile)) { + var tlsCaFilePath = Path.GetFullPath((string)tlsCaFile); + if (!string.IsNullOrEmpty(tlsCaFilePath) && !File.Exists(tlsCaFilePath)) { + throw new InvalidClientCertificateException($"Failed to load certificate. File was not found."); + } + + try { +#if NET9_0_OR_GREATER + settings.ConnectivitySettings.TlsCaFile = X509CertificateLoader.LoadCertificateFromFile(tlsCaFilePath); +#else + settings.ConnectivitySettings.TlsCaFile = new X509Certificate2(tlsCaFilePath); +#endif + } catch (CryptographicException) { + throw new InvalidClientCertificateException("Failed to load certificate. Invalid file format."); + } + } + + ConfigureClientCertificate(settings, typedOptions); + + settings.CreateHttpMessageHandler = CreateDefaultHandler; + + return settings; + +#if NET48 + HttpMessageHandler CreateDefaultHandler() { + if (settings.CreateHttpMessageHandler is not null) + return settings.CreateHttpMessageHandler.Invoke(); + + var handler = new WinHttpHandler { + TcpKeepAliveEnabled = true, + TcpKeepAliveTime = settings.ConnectivitySettings.KeepAliveTimeout, + TcpKeepAliveInterval = settings.ConnectivitySettings.KeepAliveInterval, + EnableMultipleHttp2Connections = true + }; + + if (settings.ConnectivitySettings.Insecure) return handler; + + if (settings.ConnectivitySettings.ClientCertificate is not null) + handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); + + handler.ServerCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (chain is null) return false; + + chain.ChainPolicy.ExtraStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(certificate); + }, + _ => null + }; + + return handler; + } +#else + HttpMessageHandler CreateDefaultHandler() { + var handler = new SocketsHttpHandler { + KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval, + KeepAlivePingTimeout = settings.ConnectivitySettings.KeepAliveTimeout, + EnableMultipleHttp2Connections = true + }; + + if (settings.ConnectivitySettings.Insecure) + return handler; + + if (settings.ConnectivitySettings.ClientCertificate is not null) { + handler.SslOptions.ClientCertificates = new X509CertificateCollection { + settings.ConnectivitySettings.ClientCertificate + }; + } + + handler.SslOptions.RemoteCertificateValidationCallback = settings.ConnectivitySettings.TlsVerifyCert switch { + false => delegate { return true; }, + true when settings.ConnectivitySettings.TlsCaFile is not null => (sender, certificate, chain, errors) => { + if (certificate is not X509Certificate2 peerCertificate || chain is null) return false; + + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(settings.ConnectivitySettings.TlsCaFile); + return chain.Build(peerCertificate); + }, + _ => null + }; + + return handler; + } +#endif + } + + static void ConfigureClientCertificate(KurrentClientSettings settings, IReadOnlyDictionary options) { + var certPemFilePath = GetOptionValueAsString(UserCertFile); + var keyPemFilePath = GetOptionValueAsString(UserKeyFile); + + if (string.IsNullOrEmpty(certPemFilePath) && string.IsNullOrEmpty(keyPemFilePath)) + return; + + if (string.IsNullOrEmpty(certPemFilePath) || string.IsNullOrEmpty(keyPemFilePath)) + throw new InvalidClientCertificateException("Invalid client certificate settings. Both UserCertFile and UserKeyFile must be set."); + + if (!File.Exists(certPemFilePath)) + throw new InvalidClientCertificateException( + $"Invalid client certificate settings. The specified UserCertFile does not exist: {certPemFilePath}" + ); + + if (!File.Exists(keyPemFilePath)) + throw new InvalidClientCertificateException( + $"Invalid client certificate settings. The specified UserKeyFile does not exist: {keyPemFilePath}" + ); + + try { + settings.ConnectivitySettings.ClientCertificate = + X509Certificates.CreateFromPemFile(certPemFilePath, keyPemFilePath); + } catch (Exception ex) { + throw new InvalidClientCertificateException("Failed to create client certificate.", ex); + } + + return; + + string GetOptionValueAsString(string key) => options.TryGetValue(key, out var value) ? (string)value : ""; + } + + private static string ParseScheme(string s) => + !Schemes.Contains(s) ? throw new InvalidSchemeException(s, Schemes) : s; + + private static (string, string) ParseUserInfo(string s) { + var tokens = s.Split(Colon[0]); + if (tokens.Length != 2) throw new InvalidUserCredentialsException(s); + + return (tokens[0], tokens[1]); + } + + private static EndPoint[] ParseHosts(string s) { + var hostsTokens = s.Split(Comma[0]); + var hosts = new List(); + foreach (var hostToken in hostsTokens) { + var hostPortToken = hostToken.Split(Colon[0]); + string host; + int port; + switch (hostPortToken.Length) { + case 1: + host = hostPortToken[0]; + port = DefaultPort; + break; + + case 2: { + host = hostPortToken[0]; + if (!int.TryParse(hostPortToken[1], out port)) + throw new InvalidHostException(hostToken); + + break; + } + + default: + throw new InvalidHostException(hostToken); + } + + if (host.Length == 0) { + throw new InvalidHostException(hostToken); + } + + if (IPAddress.TryParse(host, out IPAddress? ip)) { + hosts.Add(new IPEndPoint(ip, port)); + } else { + hosts.Add(new DnsEndPoint(host, port)); + } + } + + return hosts.ToArray(); + } + + private static Dictionary ParseKeyValuePairs(string s) { + var options = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + var optionsTokens = s.Split(Ampersand[0]); + foreach (var optionToken in optionsTokens) { + var (key, val) = ParseKeyValuePair(optionToken); + try { + options.Add(key, val); + } catch (ArgumentException) { + throw new DuplicateKeyException(key); + } + } + + return options; + } + + private static (string, string) ParseKeyValuePair(string s) { + var keyValueToken = s.Split(Equal[0]); + if (keyValueToken.Length != 2) { + throw new InvalidKeyValuePairException(s); + } + + return (keyValueToken[0], keyValueToken[1]); + } + } + } +} diff --git a/src/Kurrent.Client/Core/KurrentClientSettings.cs b/src/Kurrent.Client/Core/KurrentClientSettings.cs new file mode 100644 index 000000000..aed914074 --- /dev/null +++ b/src/Kurrent.Client/Core/KurrentClientSettings.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Grpc.Core; +using Grpc.Core.Interceptors; + +using Microsoft.Extensions.Logging; + +namespace EventStore.Client { + /// + /// A class that represents the settings to use for operations made from an implementation of . + /// + public partial class KurrentClientSettings { + /// + /// An optional list of s to use. + /// + public IEnumerable? Interceptors { get; set; } + + /// + /// The name of the connection. + /// + public string? ConnectionName { get; set; } + + /// + /// An optional factory. + /// + public Func? CreateHttpMessageHandler { get; set; } + + /// + /// An optional to use. + /// + public ILoggerFactory? LoggerFactory { get; set; } + + /// + /// The optional to use when creating the . + /// + public ChannelCredentials? ChannelCredentials { get; set; } + + /// + /// The default to use. + /// + public KurrentClientOperationOptions OperationOptions { get; set; } = + KurrentClientOperationOptions.Default; + + /// + /// The to use. + /// + public KurrentClientConnectivitySettings ConnectivitySettings { get; set; } = + KurrentClientConnectivitySettings.Default; + + /// + /// The optional to use if none have been supplied to the operation. + /// + public UserCredentials? DefaultCredentials { get; set; } + + /// + /// The default deadline for calls. Will not be applied to reads or subscriptions. + /// + public TimeSpan? DefaultDeadline { get; set; } = TimeSpan.FromSeconds(10); + } +} diff --git a/src/Kurrent.Client/Core/NodePreference.cs b/src/Kurrent.Client/Core/NodePreference.cs new file mode 100644 index 000000000..530edb87d --- /dev/null +++ b/src/Kurrent.Client/Core/NodePreference.cs @@ -0,0 +1,26 @@ +namespace EventStore.Client { + /// + /// Indicates the preferred KurrentDB node type to connect to. + /// + public enum NodePreference { + /// + /// When attempting connection, prefers leader node. + /// + Leader, + + /// + /// When attempting connection, prefers follower node. + /// + Follower, + + /// + /// When attempting connection, has no node preference. + /// + Random, + + /// + /// When attempting connection, prefers read only replicas. + /// + ReadOnlyReplica + } +} diff --git a/src/Kurrent.Client/Core/NodePreferenceComparers.cs b/src/Kurrent.Client/Core/NodePreferenceComparers.cs new file mode 100644 index 000000000..07e8a1cc5 --- /dev/null +++ b/src/Kurrent.Client/Core/NodePreferenceComparers.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace EventStore.Client { + internal static class NodePreferenceComparers { + public static readonly IComparer Leader = new Comparer(state => + state switch { + ClusterMessages.VNodeState.Leader => 0, + ClusterMessages.VNodeState.Follower => 1, + ClusterMessages.VNodeState.ReadOnlyReplica => 2, + ClusterMessages.VNodeState.PreReadOnlyReplica => 3, + ClusterMessages.VNodeState.ReadOnlyLeaderless => 4, + _ => int.MaxValue + }); + + public static readonly IComparer Follower = new Comparer(state => + state switch { + ClusterMessages.VNodeState.Follower => 0, + ClusterMessages.VNodeState.Leader => 1, + ClusterMessages.VNodeState.ReadOnlyReplica => 2, + ClusterMessages.VNodeState.PreReadOnlyReplica => 3, + ClusterMessages.VNodeState.ReadOnlyLeaderless => 4, + _ => int.MaxValue + }); + + public static readonly IComparer ReadOnlyReplica = new Comparer(state => + state switch { + ClusterMessages.VNodeState.ReadOnlyReplica => 0, + ClusterMessages.VNodeState.PreReadOnlyReplica => 1, + ClusterMessages.VNodeState.ReadOnlyLeaderless => 2, + ClusterMessages.VNodeState.Leader => 3, + ClusterMessages.VNodeState.Follower => 4, + _ => int.MaxValue + }); + + public static readonly IComparer None = new Comparer(_ => 0); + + private class Comparer : IComparer { + private readonly Func _getPriority; + + public Comparer(Func getPriority) { + _getPriority = getPriority; + } + + public int Compare(ClusterMessages.VNodeState left, ClusterMessages.VNodeState right) => + _getPriority(left).CompareTo(_getPriority(right)); + } + } +} diff --git a/src/Kurrent.Client/Core/NodeSelector.cs b/src/Kurrent.Client/Core/NodeSelector.cs new file mode 100644 index 000000000..661e1e190 --- /dev/null +++ b/src/Kurrent.Client/Core/NodeSelector.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace EventStore.Client { + // Selects a node to connect to from a ClusterInfo, based on the node preference. + // Deals with endpoints, no grpc here. + // Thread safe. + internal class NodeSelector { + private static readonly ClusterMessages.VNodeState[] _notAllowedStates = { + ClusterMessages.VNodeState.Manager, + ClusterMessages.VNodeState.ShuttingDown, + ClusterMessages.VNodeState.Shutdown, + ClusterMessages.VNodeState.Unknown, + ClusterMessages.VNodeState.Initializing, + ClusterMessages.VNodeState.CatchingUp, + ClusterMessages.VNodeState.ResigningLeader, + ClusterMessages.VNodeState.PreLeader, + ClusterMessages.VNodeState.PreReplica, + ClusterMessages.VNodeState.PreReadOnlyReplica, + ClusterMessages.VNodeState.Clone, + ClusterMessages.VNodeState.DiscoverLeader, + }; + + private readonly Random _random; + private readonly IComparer? _nodeStateComparer; + + public NodeSelector(KurrentClientSettings settings) { + _random = new Random(0); + _nodeStateComparer = settings.ConnectivitySettings.NodePreference switch { + NodePreference.Leader => NodePreferenceComparers.Leader, + NodePreference.Follower => NodePreferenceComparers.Follower, + NodePreference.ReadOnlyReplica => NodePreferenceComparers.ReadOnlyReplica, + _ => NodePreferenceComparers.None + }; + } + + public DnsEndPoint SelectNode(ClusterMessages.ClusterInfo clusterInfo) { + if (clusterInfo.Members.Length == 0) { + throw new Exception("No nodes in cluster info."); + } + + lock (_random) { + var node = clusterInfo.Members + .Where(IsConnectable) + .OrderBy(node => node.State, _nodeStateComparer) + .ThenBy(_ => _random.Next()) + .FirstOrDefault(); + + if (node is null) { + throw new Exception("No nodes are in a connectable state."); + } + + return node.EndPoint; + } + } + + private static bool IsConnectable(ClusterMessages.MemberInfo node) => + node.IsAlive && + !_notAllowedStates.Contains(node.State); + } +} diff --git a/src/Kurrent.Client/Core/Position.cs b/src/Kurrent.Client/Core/Position.cs new file mode 100644 index 000000000..169439804 --- /dev/null +++ b/src/Kurrent.Client/Core/Position.cs @@ -0,0 +1,200 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure referring to a potential logical record position + /// in the Event Store transaction file. + /// + public readonly struct Position : IEquatable, IComparable, IComparable, IPosition { + /// + /// Position representing the start of the transaction file + /// + public static readonly Position Start = new Position(0, 0); + + /// + /// Position representing the end of the transaction file + /// + public static readonly Position End = new Position(ulong.MaxValue, ulong.MaxValue); + + /// + /// The commit position of the record + /// + public readonly ulong CommitPosition; + + /// + /// The prepare position of the record. + /// + public readonly ulong PreparePosition; + + /// + /// Constructs a position with the given commit and prepare positions. + /// It is not guaranteed that the position is actually the start of a + /// record in the transaction file. + /// + /// The commit position cannot be less than the prepare position. + /// + /// The commit position of the record. + /// The prepare position of the record. + public Position(ulong commitPosition, ulong preparePosition) { + if (commitPosition < preparePosition) + throw new ArgumentOutOfRangeException( + nameof(commitPosition), + "The commit position cannot be less than the prepare position"); + + if (commitPosition > long.MaxValue && commitPosition != ulong.MaxValue) { + throw new ArgumentOutOfRangeException(nameof(commitPosition)); + } + + + if (preparePosition > long.MaxValue && preparePosition != ulong.MaxValue) { + throw new ArgumentOutOfRangeException(nameof(preparePosition)); + } + + CommitPosition = commitPosition; + PreparePosition = preparePosition; + } + + /// + /// Compares whether p1 < p2. + /// + /// A . + /// A . + /// True if p1 < p2. + public static bool operator <(Position p1, Position p2) => + p1.CommitPosition < p2.CommitPosition || + p1.CommitPosition == p2.CommitPosition && p1.PreparePosition < p2.PreparePosition; + + + /// + /// Compares whether p1 > p2. + /// + /// A . + /// A . + /// True if p1 > p2. + public static bool operator >(Position p1, Position p2) => + p1.CommitPosition > p2.CommitPosition || + p1.CommitPosition == p2.CommitPosition && p1.PreparePosition > p2.PreparePosition; + + /// + /// Compares whether p1 >= p2. + /// + /// A . + /// A . + /// True if p1 >= p2. + public static bool operator >=(Position p1, Position p2) => p1 > p2 || p1 == p2; + + /// + /// Compares whether p1 <= p2. + /// + /// A . + /// A . + /// True if p1 <= p2. + public static bool operator <=(Position p1, Position p2) => p1 < p2 || p1 == p2; + + /// + /// Compares p1 and p2 for equality. + /// + /// A . + /// A . + /// True if p1 is equal to p2. + public static bool operator ==(Position p1, Position p2) => + Equals(p1, p2); + + /// + /// Compares p1 and p2 for equality. + /// + /// A . + /// A . + /// True if p1 is not equal to p2. + public static bool operator !=(Position p1, Position p2) => !(p1 == p2); + + /// + public int CompareTo(Position other) => this == other ? 0 : this > other ? 1 : -1; + + /// + public int CompareTo(object? obj) => obj switch { + null => 1, + Position other => CompareTo(other), + _ => throw new ArgumentException("Object is not a Position"), + }; + + /// + /// Indicates whether this instance and a specified object are equal. + /// + /// + /// true if and this instance are the same type and represent the same value; otherwise, false. + /// + /// Another object to compare to. 2 + public override bool Equals(object? obj) => obj is Position position && Equals(position); + + /// + /// Compares this instance of for equality + /// with another instance. + /// + /// A + /// True if this instance is equal to the other instance. + public bool Equals(Position other) => + CommitPosition == other.CommitPosition && PreparePosition == other.PreparePosition; + + /// + /// Returns the hash code for this instance. + /// + /// + /// A 32-bit signed integer that is the hash code for this instance. + /// + /// 2 + public override int GetHashCode() => HashCode.Hash.Combine(CommitPosition).Combine(PreparePosition); + + /// + /// Returns the fully qualified type name of this instance. + /// + /// + /// A containing a fully qualified type name. + /// + /// 2 + public override string ToString() => $"C:{CommitPosition}/P:{PreparePosition}"; + + /// + /// Tries to convert the string representation of a to its equivalent. + /// A return value indicates whether the conversion succeeded or failed. + /// + /// A string that represents the to convert. + /// Contains the that is equivalent to the string + /// representation, if the conversion succeeded, or null if the conversion failed. + /// true if the value was converted successfully; otherwise, false. + public static bool TryParse(string value, out Position? position) { + position = null; + var parts = value.Split('/'); + + if (parts.Length != 2) { + return false; + } + + if (!TryParsePosition("C", parts[0], out var commitPosition)) { + return false; + } + + if (!TryParsePosition("P", parts[1], out var preparePosition)) { + return false; + } + + position = new Position(commitPosition, preparePosition); + return true; + + static bool TryParsePosition(string expectedPrefix, string v, out ulong p) { + p = 0; + + var prts = v.Split(':'); + if (prts.Length != 2) { + return false; + } + + if (prts[0] != expectedPrefix) { + return false; + } + + return ulong.TryParse(prts[1], out p); + } + } + } +} diff --git a/src/Kurrent.Client/Core/PrefixFilterExpression.cs b/src/Kurrent.Client/Core/PrefixFilterExpression.cs new file mode 100644 index 000000000..ea154aee3 --- /dev/null +++ b/src/Kurrent.Client/Core/PrefixFilterExpression.cs @@ -0,0 +1,64 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure representing a prefix (i.e., starts with) filter. + /// + public readonly struct PrefixFilterExpression : IEquatable { + /// + /// An empty . + /// + public static readonly PrefixFilterExpression None = default; + + private readonly string? _value; + + /// + /// Constructs a new . + /// + /// + /// + public PrefixFilterExpression(string value) { + if (value == null) { + throw new ArgumentNullException(nameof(value)); + } + + _value = value; + } + + /// + public bool Equals(PrefixFilterExpression other) => string.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is PrefixFilterExpression other && Equals(other); + + /// + public override int GetHashCode() => _value?.GetHashCode() ?? 0; + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(PrefixFilterExpression left, PrefixFilterExpression right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(PrefixFilterExpression left, PrefixFilterExpression right) => + !left.Equals(right); + + /// + /// Converts the to a . + /// + /// + /// + public static implicit operator string?(PrefixFilterExpression value) => value._value; + + /// + public override string? ToString() => _value; + } +} diff --git a/src/Kurrent.Client/Core/ReconnectionRequired.cs b/src/Kurrent.Client/Core/ReconnectionRequired.cs new file mode 100644 index 000000000..bf448971d --- /dev/null +++ b/src/Kurrent.Client/Core/ReconnectionRequired.cs @@ -0,0 +1,15 @@ +using System.Net; + +namespace EventStore.Client { + internal abstract record ReconnectionRequired { + public record None : ReconnectionRequired { + public static None Instance = new(); + } + + public record Rediscover : ReconnectionRequired { + public static Rediscover Instance = new(); + } + + public record NewLeader(DnsEndPoint EndPoint) : ReconnectionRequired; + } +} diff --git a/src/Kurrent.Client/Core/RegularFilterExpression.cs b/src/Kurrent.Client/Core/RegularFilterExpression.cs new file mode 100644 index 000000000..b8e6a42b8 --- /dev/null +++ b/src/Kurrent.Client/Core/RegularFilterExpression.cs @@ -0,0 +1,86 @@ +using System; +using System.Text.RegularExpressions; + +namespace EventStore.Client { + /// + /// A structure representing a regular expression filter. + /// + public readonly struct RegularFilterExpression : IEquatable { + /// + /// An empty . + /// + public static readonly RegularFilterExpression None = default; + + /// + /// A that excludes system events (i.e., those whose types start with $). + /// + /// + public static readonly RegularFilterExpression ExcludeSystemEvents = + new RegularFilterExpression(new Regex(@"^[^\$].*")); + + private readonly string? _value; + + /// + /// Constructs a new . + /// + /// + /// + public RegularFilterExpression(string value) { + if (value == null) { + throw new ArgumentNullException(nameof(value)); + } + + _value = value; + } + + /// + /// Constructs a new . + /// + /// + /// + public RegularFilterExpression(Regex value) { + if (value == null) { + throw new ArgumentNullException(nameof(value)); + } + + _value = value.ToString(); + } + + /// + public bool Equals(RegularFilterExpression other) => string.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is RegularFilterExpression other && Equals(other); + + /// + public override int GetHashCode() => _value?.GetHashCode() ?? 0; + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(RegularFilterExpression left, RegularFilterExpression right) => + left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(RegularFilterExpression left, RegularFilterExpression right) => + !left.Equals(right); + + /// + /// Converts a to a . + /// + /// + /// + public static implicit operator string?(RegularFilterExpression value) => value._value; + + /// + public override string? ToString() => _value; + } +} diff --git a/src/Kurrent.Client/Core/ResolvedEvent.cs b/src/Kurrent.Client/Core/ResolvedEvent.cs new file mode 100644 index 000000000..25ca13a78 --- /dev/null +++ b/src/Kurrent.Client/Core/ResolvedEvent.cs @@ -0,0 +1,60 @@ +namespace EventStore.Client { + /// + /// A structure representing a single event or a resolved link event. + /// + public readonly struct ResolvedEvent { + /// + /// If this represents a link event, the + /// will be the resolved link event, otherwise it will be the single event. + /// + public readonly EventRecord Event; + + /// + /// The link event if this is a link event. + /// + public readonly EventRecord? Link; + + /// + /// Returns the event that was read or which triggered the subscription. + /// + /// If this represents a link event, the + /// will be the , otherwise it will be . + /// + public EventRecord OriginalEvent => Link ?? Event; + + /// + /// Position of the if available. + /// + public readonly Position? OriginalPosition; + + /// + /// The stream name of the . + /// + public string OriginalStreamId => OriginalEvent.EventStreamId; + + /// + /// The in the stream of the . + /// + public StreamPosition OriginalEventNumber => OriginalEvent.EventNumber; + + /// + /// Indicates whether this is a resolved link + /// event. + /// + public bool IsResolved => Link != null && Event != null; + + /// + /// Constructs a new . + /// + /// + /// + /// + public ResolvedEvent(EventRecord @event, EventRecord? link, ulong? commitPosition) { + Event = @event; + Link = link; + OriginalPosition = commitPosition.HasValue + ? new Position(commitPosition.Value, (link ?? @event).Position.PreparePosition) + : new Position?(); + } + } +} diff --git a/src/Kurrent.Client/Core/ServerCapabilities.cs b/src/Kurrent.Client/Core/ServerCapabilities.cs new file mode 100644 index 000000000..aa63c4e3d --- /dev/null +++ b/src/Kurrent.Client/Core/ServerCapabilities.cs @@ -0,0 +1,10 @@ +namespace EventStore.Client { +#pragma warning disable 1591 + public record ServerCapabilities( + bool SupportsBatchAppend = false, + bool SupportsPersistentSubscriptionsToAll = false, + bool SupportsPersistentSubscriptionsGetInfo = false, + bool SupportsPersistentSubscriptionsRestartSubsystem = false, + bool SupportsPersistentSubscriptionsReplayParked = false, + bool SupportsPersistentSubscriptionsList = false); +} diff --git a/src/Kurrent.Client/Core/SharingProvider.cs b/src/Kurrent.Client/Core/SharingProvider.cs new file mode 100644 index 000000000..67911a618 --- /dev/null +++ b/src/Kurrent.Client/Core/SharingProvider.cs @@ -0,0 +1,111 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EventStore.Client { + internal class SharingProvider { + protected ILogger Log { get; } + + public SharingProvider(ILoggerFactory? loggerFactory) { + Log = loggerFactory?.CreateLogger() ?? + new NullLogger(); + } + } + + // Given a factory for items of type TOutput, where the items: + // - are expensive to produce + // - can be shared by consumers + // - can break + // - can fail to be successfully produced by the factory to begin with. + // + // This class will make minimal use of the factory to provide items to consumers. + // The Factory can produce and return an item, or it can throw an exception. + // We pass the factory a OnBroken callback to be called later if that instance becomes broken. + // the OnBroken callback can be called multiple times, the factory will be called once. + // the argument to the OnBroken callback is the input to construct the next item. + // + // The factory will not be called multiple times concurrently so does not need to be + // thread safe, but it does need to terminate. + // + // This class is thread safe. + + internal class SharingProvider : SharingProvider, IDisposable { + private readonly Func, Task> _factory; + private readonly TimeSpan _factoryRetryDelay; + private readonly TInput _initialInput; + private TaskCompletionSource _currentBox; + private bool _disposed; + + public SharingProvider( + Func, Task> factory, + TimeSpan factoryRetryDelay, + TInput initialInput, + ILoggerFactory? loggerFactory = null) : base(loggerFactory) { + + _factory = factory; + _factoryRetryDelay = factoryRetryDelay; + _initialInput = initialInput; + _currentBox = new(TaskCreationOptions.RunContinuationsAsynchronously); + _ = FillBoxAsync(_currentBox, input: initialInput); + } + + public Task CurrentAsync => _currentBox.Task; + + public void Reset() { + OnBroken(_currentBox, _initialInput); + } + + // Call this to return a box containing a defective item, or indeed no item at all. + // A new box will be produced and filled if necessary. + private void OnBroken(TaskCompletionSource brokenBox, TInput input) { + if (!brokenBox.Task.IsCompleted) { + // factory is still working on this box. don't create a new box to fill + // or we would have to require the factory be thread safe. + Log.LogDebug("{type} returned to factory. Production already in progress.", typeof(TOutput).Name); + return; + } + + // replace _currentBox with a new one, but only if it is the broken one. + var originalBox = Interlocked.CompareExchange( + location1: ref _currentBox, + value: new(TaskCreationOptions.RunContinuationsAsynchronously), + comparand: brokenBox); + + if (originalBox == brokenBox) { + // replaced the _currentBox, call the factory to fill it. + Log.LogDebug("{type} returned to factory. Producing a new one.", typeof(TOutput).Name); + _ = FillBoxAsync(_currentBox, input); + } else { + // did not replace. a new one was created previously. do nothing. + Log.LogDebug("{type} returned to factory. Production already complete.", typeof(TOutput).Name); + } + } + + private async Task FillBoxAsync(TaskCompletionSource box, TInput input) { + if (_disposed) { + Log.LogDebug("{type} will not be produced, factory is closed!", typeof(TOutput).Name); + box.TrySetException(new ObjectDisposedException(GetType().ToString())); + return; + } + + try { + Log.LogDebug("{type} being produced...", typeof(TOutput).Name); + var item = await _factory(input, x => OnBroken(box, x)).ConfigureAwait(false); + box.TrySetResult(item); + Log.LogDebug("{type} produced!", typeof(TOutput).Name); + } catch (Exception ex) { + await Task.Yield(); // avoid risk of stack overflow + Log.LogDebug(ex, "{type} production failed. Retrying in {delay}", typeof(TOutput).Name, _factoryRetryDelay); + await Task.Delay(_factoryRetryDelay).ConfigureAwait(false); + box.TrySetException(ex); + OnBroken(box, _initialInput); + } + } + + public void Dispose() { + _disposed = true; + } + } +} diff --git a/src/Kurrent.Client/Core/SingleNodeChannelSelector.cs b/src/Kurrent.Client/Core/SingleNodeChannelSelector.cs new file mode 100644 index 000000000..83449e689 --- /dev/null +++ b/src/Kurrent.Client/Core/SingleNodeChannelSelector.cs @@ -0,0 +1,36 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EventStore.Client { + internal class SingleNodeChannelSelector : IChannelSelector { + private readonly ILogger _log; + private readonly ChannelCache _channelCache; + private readonly DnsEndPoint _endPoint; + + public SingleNodeChannelSelector( + KurrentClientSettings settings, + ChannelCache channelCache) { + + _log = settings.LoggerFactory?.CreateLogger() ?? + new NullLogger(); + + _channelCache = channelCache; + + var uri = settings.ConnectivitySettings.ResolvedAddressOrDefault; + _endPoint = new DnsEndPoint(host: uri.Host, port: uri.Port); + } + + public Task SelectChannelAsync(CancellationToken cancellationToken) => + Task.FromResult(SelectChannel(_endPoint)); + + public ChannelBase SelectChannel(DnsEndPoint endPoint) { + _log.LogInformation("Selected {endPoint}.", endPoint); + + return _channelCache.GetChannelInfo(endPoint); + } + } +} diff --git a/src/Kurrent.Client/Core/SingleNodeHttpHandler.cs b/src/Kurrent.Client/Core/SingleNodeHttpHandler.cs new file mode 100644 index 000000000..0c346a00f --- /dev/null +++ b/src/Kurrent.Client/Core/SingleNodeHttpHandler.cs @@ -0,0 +1,22 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace EventStore.Client { + internal class SingleNodeHttpHandler : DelegatingHandler { + private readonly KurrentClientSettings _settings; + + public SingleNodeHttpHandler(KurrentClientSettings settings) { + _settings = settings; + } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) { + request.RequestUri = new UriBuilder(request.RequestUri!) { + Scheme = _settings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme + }.Uri; + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Kurrent.Client/Core/StreamFilter.cs b/src/Kurrent.Client/Core/StreamFilter.cs new file mode 100644 index 000000000..8ed70e7bb --- /dev/null +++ b/src/Kurrent.Client/Core/StreamFilter.cs @@ -0,0 +1,134 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace EventStore.Client { + /// + /// A structure representing a filter on stream names for read operations. + /// + public readonly struct StreamFilter : IEquatable, IEventFilter { + /// + /// An empty . + /// + public static readonly StreamFilter None = default; + + readonly PrefixFilterExpression[] _prefixes; + + /// + public PrefixFilterExpression[] Prefixes => _prefixes ?? Array.Empty(); + + /// + public RegularFilterExpression Regex { get; } + + /// + public uint? MaxSearchWindow { get; } + + /// + /// Creates a from a single prefix. + /// + /// + /// + public static IEventFilter Prefix(string prefix) + => new StreamFilter(new PrefixFilterExpression(prefix)); + + /// + /// Creates a from multiple prefixes. + /// + /// + /// + public static IEventFilter Prefix(params string[] prefixes) + => new StreamFilter(Array.ConvertAll(prefixes, prefix => new PrefixFilterExpression(prefix))); + + /// + /// Creates a from a search window and multiple prefixes. + /// + /// + /// + /// + public static IEventFilter Prefix(uint maxSearchWindow, params string[] prefixes) + => new StreamFilter(maxSearchWindow, + Array.ConvertAll(prefixes, prefix => new PrefixFilterExpression(prefix))); + + /// + /// Creates a from a regular expression and a search window. + /// + /// + /// + /// + public static IEventFilter RegularExpression(string regex, uint maxSearchWindow = 32) + => new StreamFilter(maxSearchWindow, new RegularFilterExpression(regex)); + + /// + /// Creates a from a regular expression and a search window. + /// + /// + /// + /// + public static IEventFilter RegularExpression(Regex regex, uint maxSearchWindow = 32) + => new StreamFilter(maxSearchWindow, new RegularFilterExpression(regex)); + + StreamFilter(RegularFilterExpression regex) : this(default, regex) { } + + StreamFilter(uint maxSearchWindow, RegularFilterExpression regex) { + if (maxSearchWindow == 0) { + throw new ArgumentOutOfRangeException(nameof(maxSearchWindow), + maxSearchWindow, $"{nameof(maxSearchWindow)} must be greater than 0."); + } + + Regex = regex; + _prefixes = Array.Empty(); + MaxSearchWindow = maxSearchWindow; + } + + StreamFilter(params PrefixFilterExpression[] prefixes) : this(32, prefixes) { } + + StreamFilter(uint maxSearchWindow, params PrefixFilterExpression[] prefixes) { + if (prefixes.Length == 0) { + throw new ArgumentException(); + } + + if (maxSearchWindow == 0) { + throw new ArgumentOutOfRangeException(nameof(maxSearchWindow), + maxSearchWindow, $"{nameof(maxSearchWindow)} must be greater than 0."); + } + + _prefixes = prefixes; + Regex = RegularFilterExpression.None; + MaxSearchWindow = maxSearchWindow; + } + + /// + public bool Equals(StreamFilter other) => + Prefixes.SequenceEqual(other.Prefixes) && + Regex.Equals(other.Regex) && + MaxSearchWindow.Equals(other.MaxSearchWindow); + + /// + public override bool Equals(object? obj) => obj is StreamFilter other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(Prefixes).Combine(Regex).Combine(MaxSearchWindow); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(StreamFilter left, StreamFilter right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(StreamFilter left, StreamFilter right) => !left.Equals(right); + + /// + public override string ToString() => + this == None + ? "(none)" + : $"{nameof(StreamFilter)} {(Prefixes.Length == 0 ? Regex.ToString() : $"[{string.Join(", ", Prefixes)}]")}"; + } +} diff --git a/src/Kurrent.Client/Core/StreamIdentifier.cs b/src/Kurrent.Client/Core/StreamIdentifier.cs new file mode 100644 index 000000000..9ca6c9e7e --- /dev/null +++ b/src/Kurrent.Client/Core/StreamIdentifier.cs @@ -0,0 +1,28 @@ +using System.Text; +using Google.Protobuf; + +namespace EventStore.Client { +#pragma warning disable 1591 + internal partial class StreamIdentifier { + private string? _cached; + + public static implicit operator string?(StreamIdentifier? source) { + if (source == null) { + return null; + } + if (source._cached != null || source.StreamName.IsEmpty) return source._cached; + +#if NET + var tmp = Encoding.UTF8.GetString(source.StreamName.Span); +#else + var tmp = Encoding.UTF8.GetString(source.StreamName.ToByteArray()); +#endif + //this doesn't have to be thread safe, its just a cache in case the identifier is turned into a string several times + source._cached = tmp; + return source._cached; + } + + public static implicit operator StreamIdentifier(string source) => + new() {StreamName = ByteString.CopyFromUtf8(source)}; + } +} diff --git a/src/Kurrent.Client/Core/StreamPosition.cs b/src/Kurrent.Client/Core/StreamPosition.cs new file mode 100644 index 000000000..7c196c7da --- /dev/null +++ b/src/Kurrent.Client/Core/StreamPosition.cs @@ -0,0 +1,201 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure referring to an 's position within a stream. + /// + public readonly struct StreamPosition : IEquatable, IComparable, IComparable, IPosition { + private readonly ulong _value; + + /// + /// The beginning (i.e., the first event) of a stream. + /// + public static readonly StreamPosition Start = new StreamPosition(0); + + /// + /// The end of a stream. Use this when reading a stream backwards, or subscribing live to a stream. + /// + public static readonly StreamPosition End = new StreamPosition(ulong.MaxValue); + + /// + /// Converts a to a . It is not meant to be used directly from your code. + /// + /// + /// + public static StreamPosition FromInt64(long value) => + value == -1 ? End : new StreamPosition(Convert.ToUInt64(value)); + + /// + /// Creates a from a . + /// + /// + /// + public static StreamPosition FromStreamRevision(StreamRevision revision) => revision.ToUInt64() switch { + ulong.MaxValue => throw new ArgumentOutOfRangeException(nameof(revision)), + _ => new StreamPosition(revision.ToUInt64()) + }; + + /// + /// Constructs a new . + /// + /// + /// + public StreamPosition(ulong value) { + if (value > long.MaxValue && value != ulong.MaxValue) { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _value = value; + } + + /// + /// Advance to the next . + /// + /// + public StreamPosition Next() => this + 1; + + /// + public int CompareTo(StreamPosition other) => _value.CompareTo(other._value); + + /// + public int CompareTo(object? obj) => obj switch { + null => 1, + StreamPosition other => CompareTo(other), + _ => throw new ArgumentException("Object is not a StreamPosition"), + }; + + /// + public bool Equals(StreamPosition other) => _value == other._value; + + /// + public override bool Equals(object? obj) => obj is StreamPosition other && Equals(other); + + /// + public override int GetHashCode() => _value.GetHashCode(); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(StreamPosition left, StreamPosition right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(StreamPosition left, StreamPosition right) => !left.Equals(right); + + /// + /// Adds right to left. + /// + /// + /// + /// + public static StreamPosition operator +(StreamPosition left, ulong right) { + checked { + return new StreamPosition(left._value + right); + } + } + + /// + /// Adds right to left. + /// + /// + /// + /// + public static StreamPosition operator +(ulong left, StreamPosition right) { + checked { + return new StreamPosition(left + right._value); + } + } + + /// + /// Subtracts right from left. + /// + /// + /// + /// + public static StreamPosition operator -(StreamPosition left, ulong right) { + checked { + return new StreamPosition(left._value - right); + } + } + + /// + /// Subtracts right from left. + /// + /// + /// + /// + public static StreamPosition operator -(ulong left, StreamPosition right) { + checked { + return new StreamPosition(left - right._value); + } + } + + /// + /// Compares whether left > right. + /// + /// + /// + /// + public static bool operator >(StreamPosition left, StreamPosition right) => left._value > right._value; + + /// + /// Compares whether left < right. + /// + /// + /// + /// + public static bool operator <(StreamPosition left, StreamPosition right) => left._value < right._value; + + /// + /// Compares whether left >= right. + /// + /// + /// + /// + public static bool operator >=(StreamPosition left, StreamPosition right) => left._value >= right._value; + + /// + /// Compares whether left <= right. + /// + /// + /// + /// + public static bool operator <=(StreamPosition left, StreamPosition right) => left._value <= right._value; + + /// + /// Converts the to a . It is not meant to be used directly from your code. + /// + /// + public long ToInt64() => Equals(End) ? -1 : Convert.ToInt64(_value); + + /// + /// Converts a to a . + /// + /// + /// + public static implicit operator ulong(StreamPosition streamPosition) => streamPosition._value; + + /// + /// Converts a to a . + /// + /// + /// + public static implicit operator StreamPosition(ulong value) => new StreamPosition(value); + + /// + public override string ToString() => this == End ? "End" : _value.ToString(); + + /// + /// Converts the to a . + /// + /// + public ulong ToUInt64() => _value; + } +} diff --git a/src/Kurrent.Client/Core/StreamRevision.cs b/src/Kurrent.Client/Core/StreamRevision.cs new file mode 100644 index 000000000..89b553f53 --- /dev/null +++ b/src/Kurrent.Client/Core/StreamRevision.cs @@ -0,0 +1,196 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure referring to the expected stream revision when writing to a stream. + /// + public readonly struct StreamRevision : IEquatable, IComparable, IComparable { + private readonly ulong _value; + + /// + /// Represents no , i.e., when a stream does not exist. + /// + public static readonly StreamRevision None = new StreamRevision(ulong.MaxValue); + + /// + /// Converts a to a . It is not meant to be used directly from your code. + /// + /// + /// + public static StreamRevision FromInt64(long value) => + value == -1 ? None : new StreamRevision(Convert.ToUInt64(value)); + + /// + /// Creates a new from the given . + /// + /// + /// + public static StreamRevision FromStreamPosition(StreamPosition position) => position.ToUInt64() switch { + ulong.MaxValue => throw new ArgumentOutOfRangeException(nameof(position)), + _ => new StreamRevision(position.ToUInt64()) + }; + + /// + /// Constructs a new . + /// + /// + /// + public StreamRevision(ulong value) { + if (value > long.MaxValue && value != ulong.MaxValue) { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _value = value; + } + + /// + /// Advances the to the next revision. + /// + /// + public StreamRevision Next() => this + 1; + + /// + public int CompareTo(StreamRevision other) => _value.CompareTo(other._value); + + /// + public int CompareTo(object? obj) => obj switch { + null => 1, + StreamRevision other => CompareTo(other), + _ => throw new ArgumentException("Object is not a StreamRevision"), + }; + + /// + public bool Equals(StreamRevision other) => _value == other._value; + + /// + public override bool Equals(object? obj) => obj is StreamRevision other && Equals(other); + + /// + public override int GetHashCode() => _value.GetHashCode(); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(StreamRevision left, StreamRevision right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(StreamRevision left, StreamRevision right) => !left.Equals(right); + + /// + /// Adds right to left. + /// + /// + /// + /// + public static StreamRevision operator +(StreamRevision left, ulong right) { + checked { + return new StreamRevision(left._value + right); + } + } + + /// + /// Adds right to left. + /// + /// + /// + /// + public static StreamRevision operator +(ulong left, StreamRevision right) { + checked { + return new StreamRevision(left + right._value); + } + } + + /// + /// Subtracts right from left. + /// + /// + /// + /// + public static StreamRevision operator -(StreamRevision left, ulong right) { + checked { + return new StreamRevision(left._value - right); + } + } + + /// + /// Subtracts right from left. + /// + /// + /// + /// + public static StreamRevision operator -(ulong left, StreamRevision right) { + checked { + return new StreamRevision(left - right._value); + } + } + + /// + /// Compares whether left > right. + /// + /// + /// + /// + public static bool operator >(StreamRevision left, StreamRevision right) => left._value > right._value; + + /// + /// Compares whether left < right. + /// + /// + /// + /// + public static bool operator <(StreamRevision left, StreamRevision right) => left._value < right._value; + + /// + /// Compares whether left >= right. + /// + /// + /// + /// + public static bool operator >=(StreamRevision left, StreamRevision right) => left._value >= right._value; + + /// + /// Compares whether left <= right. + /// + /// + /// + /// + public static bool operator <=(StreamRevision left, StreamRevision right) => left._value <= right._value; + + /// + /// Converts the to a . It is not meant to be used directly from your code. + /// + /// + public long ToInt64() => Equals(None) ? -1 : Convert.ToInt64(_value); + + /// + /// Converts a to a . + /// + /// + /// + public static implicit operator ulong(StreamRevision streamRevision) => streamRevision._value; + + /// + /// Converts a to a . + /// + /// + /// + public static implicit operator StreamRevision(ulong value) => new StreamRevision(value); + + /// + public override string ToString() => this == None ? nameof(None) : _value.ToString(); + + /// + /// Converts the to a . + /// + /// + public ulong ToUInt64() => _value; + } +} diff --git a/src/Kurrent.Client/Core/StreamState.cs b/src/Kurrent.Client/Core/StreamState.cs new file mode 100644 index 000000000..a0a021726 --- /dev/null +++ b/src/Kurrent.Client/Core/StreamState.cs @@ -0,0 +1,88 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure that represents the state the stream should be in when writing. + /// + public readonly struct StreamState : IEquatable { + /// + /// The stream should not exist. + /// + public static readonly StreamState NoStream = new StreamState(Constants.NoStream); + + /// + /// The stream may or may not exist. + /// + public static readonly StreamState Any = new StreamState(Constants.Any); + + /// + /// The stream must exist. + /// + public static readonly StreamState StreamExists = new StreamState(Constants.StreamExists); + + private readonly int _value; + + private static class Constants { + public const int NoStream = 1; + public const int Any = 2; + public const int StreamExists = 4; + } + + internal StreamState(int value) { + switch (value) { + case Constants.NoStream: + case Constants.Any: + case Constants.StreamExists: + _value = value; + return; + default: + throw new ArgumentOutOfRangeException(nameof(value)); + } + } + + /// + public bool Equals(StreamState other) => _value == other._value; + + /// + public override bool Equals(object? obj) => obj is StreamState other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(_value); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// Returns True when left and right are equal. + public static bool operator ==(StreamState left, StreamState right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// Returns True when left and right are not equal. + public static bool operator !=(StreamState left, StreamState right) => !left.Equals(right); + + /// + /// Converts the to a . It is not meant to be used directly from your code. + /// + /// + public long ToInt64() => -Convert.ToInt64(_value); + + /// + /// Converts the to an . It is not meant to be used directly from your code. + /// + /// + public static implicit operator int(StreamState streamState) => streamState._value; + + /// + public override string ToString() => _value switch { + Constants.NoStream => nameof(NoStream), + Constants.Any => nameof(Any), + Constants.StreamExists => nameof(StreamExists), + _ => _value.ToString() + }; + } +} diff --git a/src/Kurrent.Client/Core/SubscriptionDroppedReason.cs b/src/Kurrent.Client/Core/SubscriptionDroppedReason.cs new file mode 100644 index 000000000..1865a5d44 --- /dev/null +++ b/src/Kurrent.Client/Core/SubscriptionDroppedReason.cs @@ -0,0 +1,19 @@ +namespace EventStore.Client { + /// + /// Represents the reason subscription was dropped. + /// + public enum SubscriptionDroppedReason { + /// + /// Subscription was dropped because the subscription was disposed. + /// + Disposed, + /// + /// Subscription was dropped because of an error in user code. + /// + SubscriberError, + /// + /// Subscription was dropped because of a server error. + /// + ServerError + } +} diff --git a/src/Kurrent.Client/Core/SystemRoles.cs b/src/Kurrent.Client/Core/SystemRoles.cs new file mode 100644 index 000000000..b9cb068f7 --- /dev/null +++ b/src/Kurrent.Client/Core/SystemRoles.cs @@ -0,0 +1,21 @@ +namespace EventStore.Client { + /// + /// Roles used by the system. + /// + public static class SystemRoles { + /// + /// The $admins role. + /// + public const string Admins = "$admins"; + + /// + /// The $ops role. + /// + public const string Operations = "$ops"; + + /// + /// The $all role. + /// + public const string All = "$all"; + } +} diff --git a/src/Kurrent.Client/Core/SystemStreams.cs b/src/Kurrent.Client/Core/SystemStreams.cs new file mode 100644 index 000000000..c8b72503a --- /dev/null +++ b/src/Kurrent.Client/Core/SystemStreams.cs @@ -0,0 +1,54 @@ +namespace EventStore.Client { + /// + /// A collection of constants and methods to identify streams. + /// + public static class SystemStreams { + /// + /// A stream containing all events in the KurrentDB transaction file. + /// + public const string AllStream = "$all"; + + /// + /// A stream containing links pointing to each stream in the KurrentDB. + /// + public const string StreamsStream = "$streams"; + + /// + /// A stream containing system settings. + /// + public const string SettingsStream = "$settings"; + + /// + /// A stream containing statistics. + /// + public const string StatsStreamPrefix = "$stats"; + + /// + /// Returns True if the stream is a system stream. + /// + /// + /// + public static bool IsSystemStream(string streamId) => streamId.Length != 0 && streamId[0] == '$'; + + /// + /// Returns the metadata stream of the stream. + /// + /// + /// + public static string MetastreamOf(string streamId) => "$$" + streamId; + + /// + /// Returns true if the stream is a metadata stream. + /// + /// + /// + public static bool IsMetastream(string streamId) => streamId[..2] == "$$"; + + /// + /// Returns the original stream of the metadata stream. + /// + /// + /// + public static string OriginalStreamOf(string metastreamId) => metastreamId[2..]; + } +} diff --git a/src/Kurrent.Client/Core/TaskExtensions.cs b/src/Kurrent.Client/Core/TaskExtensions.cs new file mode 100644 index 000000000..5511b1c20 --- /dev/null +++ b/src/Kurrent.Client/Core/TaskExtensions.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace EventStore.Client { + internal static class TaskExtensions { + // To give up waiting for the task, cancel the token. + // obvs this wouldn't cancel the task itself. + public static async ValueTask WithCancellation(this Task task, CancellationToken cancellationToken) { + if (task.Status == TaskStatus.RanToCompletion) + return task.Result; + + await Task + .WhenAny( + task, + Task.Delay(-1, cancellationToken)) + .ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + return await task.ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/Core/UserCredentials.cs b/src/Kurrent.Client/Core/UserCredentials.cs new file mode 100644 index 000000000..6f9012951 --- /dev/null +++ b/src/Kurrent.Client/Core/UserCredentials.cs @@ -0,0 +1,54 @@ +using System.Net.Http.Headers; +using System.Text; +using static System.Convert; + +namespace EventStore.Client { + /// + /// Represents either a username/password pair or a JWT token used for authentication and + /// authorization to perform operations on the KurrentDB. + /// + public class UserCredentials { + // ReSharper disable once InconsistentNaming + static readonly UTF8Encoding UTF8NoBom = new UTF8Encoding(false); + + /// + /// Constructs a new . + /// + public UserCredentials(string username, string password) { + Username = username; + Password = password; + + Authorization = new( + Constants.Headers.BasicScheme, + ToBase64String(UTF8NoBom.GetBytes($"{username}:{password}")) + ); + } + + /// + /// Constructs a new . + /// + public UserCredentials(string bearerToken) { + Authorization = new(Constants.Headers.BearerScheme, bearerToken); + } + + AuthenticationHeaderValue Authorization { get; } + + /// + /// The username + /// + public string? Username { get; } + + /// + /// The password + /// + public string? Password { get; } + + /// + public override string ToString() => Authorization.ToString(); + + /// + /// Implicitly convert a to a . + /// + public static implicit operator string(UserCredentials self) => self.ToString(); + } +} diff --git a/src/Kurrent.Client/Core/Uuid.cs b/src/Kurrent.Client/Core/Uuid.cs new file mode 100644 index 000000000..cbe7a7686 --- /dev/null +++ b/src/Kurrent.Client/Core/Uuid.cs @@ -0,0 +1,203 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace EventStore.Client { + /// + /// An RFC-4122 compliant v4 UUID. + /// + public readonly struct Uuid : IEquatable { + /// + /// Represents the empty (00000000-0000-0000-0000-000000000000) . + /// + /// + /// This reorders the bits in System.Guid to improve interop with other languages. See: https://stackoverflow.com/a/16722909 + /// + public static readonly Uuid Empty = new Uuid(Guid.Empty); + + private readonly long _lsb; + private readonly long _msb; + + /// + /// Creates a new, randomized . + /// + /// + public static Uuid NewUuid() => new Uuid(Guid.NewGuid()); + + /// + /// Converts a to a . + /// + /// + /// + public static Uuid FromGuid(Guid value) => new Uuid(value); + + /// + /// Parses a into a . + /// + /// + /// + public static Uuid Parse(string value) => new Uuid(value); + + /// + /// Creates a from a pair of . + /// + /// The representing the most significant bits. + /// The representing the least significant bits. + /// + public static Uuid FromInt64(long msb, long lsb) => new Uuid(msb, lsb); + + /// + /// Creates a from the gRPC wire format. + /// + /// + /// + internal static Uuid FromDto(UUID dto) => + dto == null + ? throw new ArgumentNullException(nameof(dto)) + : dto.ValueCase switch { + UUID.ValueOneofCase.String => new Uuid(dto.String), + UUID.ValueOneofCase.Structured => new Uuid(dto.Structured.MostSignificantBits, + dto.Structured.LeastSignificantBits), + _ => throw new ArgumentException($"Invalid argument: {dto.ValueCase}", nameof(dto)) + }; + + private Uuid(Guid value) { + if (!BitConverter.IsLittleEndian) { + throw new NotSupportedException(); + } + + Span data = stackalloc byte[16]; + + if (!TryWriteGuidBytes(value, data)) { + throw new InvalidOperationException(); + } + + data[..8].Reverse(); + data[..2].Reverse(); + data.Slice(2, 2).Reverse(); + data.Slice(4, 4).Reverse(); + data[8..].Reverse(); + + _msb = BitConverterToInt64(data); + _lsb = BitConverterToInt64(data[8..]); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long BitConverterToInt64(ReadOnlySpan value) + { +#if NET + return BitConverter.ToInt64(value); +#else + return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(value)); +#endif + } + + private Uuid(string value) : this(value == null + ? throw new ArgumentNullException(nameof(value)) + : Guid.Parse(value)) { + } + + private Uuid(long msb, long lsb) { + _msb = msb; + _lsb = lsb; + } + + /// + /// Converts the to the gRPC wire format. + /// + /// + internal UUID ToDto() => + new UUID { + Structured = new UUID.Types.Structured { + LeastSignificantBits = _lsb, + MostSignificantBits = _msb + } + }; + + + /// + public bool Equals(Uuid other) => _lsb == other._lsb && _msb == other._msb; + + /// + public override bool Equals(object? obj) => obj is Uuid other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(_lsb).Combine(_msb); + + /// + /// Compares left and right for equality. + /// + /// A + /// A + /// True if left is equal to right. + public static bool operator ==(Uuid left, Uuid right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// A + /// A + /// True if left is not equal to right. + public static bool operator !=(Uuid left, Uuid right) => !left.Equals(right); + + /// + public override string ToString() => ToGuid().ToString(); + + /// + /// Converts the to a based on the supplied format. + /// + /// + /// + public string ToString(string format) => ToGuid().ToString(format); + + /// + /// Converts the to a . + /// + /// + public Guid ToGuid() { + if (!BitConverter.IsLittleEndian) { + throw new NotSupportedException(); + } + + Span data = stackalloc byte[16]; + if (!TryWriteBytes(data, _msb) || + !TryWriteBytes(data[8..], _lsb)) { + throw new InvalidOperationException(); + } + + data[..8].Reverse(); + data[..4].Reverse(); + data.Slice(4, 2).Reverse(); + data.Slice(6, 2).Reverse(); + data[8..].Reverse(); + +#if NET + return new Guid(data); +#else + return new Guid(data.ToArray()); +#endif + } + private static bool TryWriteBytes(Span destination, long value) + { + if (destination.Length < sizeof(long)) + return false; + + Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(destination), value); + return true; + } + + private bool TryWriteGuidBytes(Guid value, Span destination) + { +#if NET + return value.TryWriteBytes(destination); +#else + if (destination.Length < 16) + return false; + + var bytes = value.ToByteArray(); + bytes.CopyTo(destination); + return true; +#endif + } + } +} diff --git a/src/Kurrent.Client/Core/protos/code.proto b/src/Kurrent.Client/Core/protos/code.proto new file mode 100644 index 000000000..98ae0ac18 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/code.proto @@ -0,0 +1,186 @@ +// Copyright 2020 Google LLC +// +// 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. + +syntax = "proto3"; + +package google.rpc; + +option go_package = "google.golang.org/genproto/googleapis/rpc/code;code"; +option java_multiple_files = true; +option java_outer_classname = "CodeProto"; +option java_package = "com.google.rpc"; +option objc_class_prefix = "RPC"; + +// The canonical error codes for gRPC APIs. +// +// +// Sometimes multiple error codes may apply. Services should return +// the most specific error code that applies. For example, prefer +// `OUT_OF_RANGE` over `FAILED_PRECONDITION` if both codes apply. +// Similarly prefer `NOT_FOUND` or `ALREADY_EXISTS` over `FAILED_PRECONDITION`. +enum Code { + // Not an error; returned on success + // + // HTTP Mapping: 200 OK + OK = 0; + + // The operation was cancelled, typically by the caller. + // + // HTTP Mapping: 499 Client Closed Request + CANCELLED = 1; + + // Unknown error. For example, this error may be returned when + // a `Status` value received from another address space belongs to + // an error space that is not known in this address space. Also + // errors raised by APIs that do not return enough error information + // may be converted to this error. + // + // HTTP Mapping: 500 Internal Server Error + UNKNOWN = 2; + + // The client specified an invalid argument. Note that this differs + // from `FAILED_PRECONDITION`. `INVALID_ARGUMENT` indicates arguments + // that are problematic regardless of the state of the system + // (e.g., a malformed file name). + // + // HTTP Mapping: 400 Bad Request + INVALID_ARGUMENT = 3; + + // The deadline expired before the operation could complete. For operations + // that change the state of the system, this error may be returned + // even if the operation has completed successfully. For example, a + // successful response from a server could have been delayed long + // enough for the deadline to expire. + // + // HTTP Mapping: 504 Gateway Timeout + DEADLINE_EXCEEDED = 4; + + // Some requested entity (e.g., file or directory) was not found. + // + // Note to server developers: if a request is denied for an entire class + // of users, such as gradual feature rollout or undocumented whitelist, + // `NOT_FOUND` may be used. If a request is denied for some users within + // a class of users, such as user-based access control, `PERMISSION_DENIED` + // must be used. + // + // HTTP Mapping: 404 Not Found + NOT_FOUND = 5; + + // The entity that a client attempted to create (e.g., file or directory) + // already exists. + // + // HTTP Mapping: 409 Conflict + ALREADY_EXISTS = 6; + + // The caller does not have permission to execute the specified + // operation. `PERMISSION_DENIED` must not be used for rejections + // caused by exhausting some resource (use `RESOURCE_EXHAUSTED` + // instead for those errors). `PERMISSION_DENIED` must not be + // used if the caller can not be identified (use `UNAUTHENTICATED` + // instead for those errors). This error code does not imply the + // request is valid or the requested entity exists or satisfies + // other pre-conditions. + // + // HTTP Mapping: 403 Forbidden + PERMISSION_DENIED = 7; + + // The request does not have valid authentication credentials for the + // operation. + // + // HTTP Mapping: 401 Unauthorized + UNAUTHENTICATED = 16; + + // Some resource has been exhausted, perhaps a per-user quota, or + // perhaps the entire file system is out of space. + // + // HTTP Mapping: 429 Too Many Requests + RESOURCE_EXHAUSTED = 8; + + // The operation was rejected because the system is not in a state + // required for the operation's execution. For example, the directory + // to be deleted is non-empty, an rmdir operation is applied to + // a non-directory, etc. + // + // Service implementors can use the following guidelines to decide + // between `FAILED_PRECONDITION`, `ABORTED`, and `UNAVAILABLE`: + // (a) Use `UNAVAILABLE` if the client can retry just the failing call. + // (b) Use `ABORTED` if the client should retry at a higher level + // (e.g., when a client-specified test-and-set fails, indicating the + // client should restart a read-modify-write sequence). + // (c) Use `FAILED_PRECONDITION` if the client should not retry until + // the system state has been explicitly fixed. E.g., if an "rmdir" + // fails because the directory is non-empty, `FAILED_PRECONDITION` + // should be returned since the client should not retry unless + // the files are deleted from the directory. + // + // HTTP Mapping: 400 Bad Request + FAILED_PRECONDITION = 9; + + // The operation was aborted, typically due to a concurrency issue such as + // a sequencer check failure or transaction abort. + // + // See the guidelines above for deciding between `FAILED_PRECONDITION`, + // `ABORTED`, and `UNAVAILABLE`. + // + // HTTP Mapping: 409 Conflict + ABORTED = 10; + + // The operation was attempted past the valid range. E.g., seeking or + // reading past end-of-file. + // + // Unlike `INVALID_ARGUMENT`, this error indicates a problem that may + // be fixed if the system state changes. For example, a 32-bit file + // system will generate `INVALID_ARGUMENT` if asked to read at an + // offset that is not in the range [0,2^32-1], but it will generate + // `OUT_OF_RANGE` if asked to read from an offset past the current + // file size. + // + // There is a fair bit of overlap between `FAILED_PRECONDITION` and + // `OUT_OF_RANGE`. We recommend using `OUT_OF_RANGE` (the more specific + // error) when it applies so that callers who are iterating through + // a space can easily look for an `OUT_OF_RANGE` error to detect when + // they are done. + // + // HTTP Mapping: 400 Bad Request + OUT_OF_RANGE = 11; + + // The operation is not implemented or is not supported/enabled in this + // service. + // + // HTTP Mapping: 501 Not Implemented + UNIMPLEMENTED = 12; + + // Internal errors. This means that some invariants expected by the + // underlying system have been broken. This error code is reserved + // for serious errors. + // + // HTTP Mapping: 500 Internal Server Error + INTERNAL = 13; + + // The service is currently unavailable. This is most likely a + // transient condition, which can be corrected by retrying with + // a backoff. Note that it is not always safe to retry + // non-idempotent operations. + // + // See the guidelines above for deciding between `FAILED_PRECONDITION`, + // `ABORTED`, and `UNAVAILABLE`. + // + // HTTP Mapping: 503 Service Unavailable + UNAVAILABLE = 14; + + // Unrecoverable data loss or corruption. + // + // HTTP Mapping: 500 Internal Server Error + DATA_LOSS = 15; +} diff --git a/src/Kurrent.Client/Core/protos/gossip.proto b/src/Kurrent.Client/Core/protos/gossip.proto new file mode 100644 index 000000000..f4ea9bcd4 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/gossip.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; +package event_store.client.gossip; +option java_package = "io.kurrent.client.gossip"; + +import "shared.proto"; + +service Gossip { + rpc Read (event_store.client.Empty) returns (ClusterInfo); +} + +message ClusterInfo { + repeated MemberInfo members = 1; +} + +message EndPoint { + string address = 1; + uint32 port = 2; +} + +message MemberInfo { + enum VNodeState { + Initializing = 0; + DiscoverLeader = 1; + Unknown = 2; + PreReplica = 3; + CatchingUp = 4; + Clone = 5; + Follower = 6; + PreLeader = 7; + Leader = 8; + Manager = 9; + ShuttingDown = 10; + Shutdown = 11; + ReadOnlyLeaderless = 12; + PreReadOnlyReplica = 13; + ReadOnlyReplica = 14; + ResigningLeader = 15; + } + event_store.client.UUID instance_id = 1; + int64 time_stamp = 2; + VNodeState state = 3; + bool is_alive = 4; + EndPoint http_end_point = 5; +} diff --git a/src/Kurrent.Client/Core/protos/operations.proto b/src/Kurrent.Client/Core/protos/operations.proto new file mode 100644 index 000000000..f4f9ae3c3 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/operations.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; +package event_store.client.operations; +option java_package = "io.kurrent.client.operations"; + +import "shared.proto"; + +service Operations { + rpc StartScavenge (StartScavengeReq) returns (ScavengeResp); + rpc StopScavenge (StopScavengeReq) returns (ScavengeResp); + rpc Shutdown (Empty) returns (Empty); + rpc MergeIndexes (Empty) returns (Empty); + rpc ResignNode (Empty) returns (Empty); + rpc SetNodePriority (SetNodePriorityReq) returns (Empty); + rpc RestartPersistentSubscriptions (Empty) returns (Empty); +} + +message StartScavengeReq { + Options options = 1; + message Options { + int32 thread_count = 1; + int32 start_from_chunk = 2; + } +} + +message StopScavengeReq { + Options options = 1; + message Options { + string scavenge_id = 1; + } +} + +message ScavengeResp { + string scavenge_id = 1; + ScavengeResult scavenge_result = 2; + + enum ScavengeResult { + Started = 0; + InProgress = 1; + Stopped = 2; + } +} + +message SetNodePriorityReq { + int32 priority = 1; +} diff --git a/src/Kurrent.Client/Core/protos/persistentsubscriptions.proto b/src/Kurrent.Client/Core/protos/persistentsubscriptions.proto new file mode 100644 index 000000000..ac63a3a6d --- /dev/null +++ b/src/Kurrent.Client/Core/protos/persistentsubscriptions.proto @@ -0,0 +1,370 @@ +syntax = "proto3"; +package event_store.client.persistent_subscriptions; +option java_package = "io.kurrent.dbclient.proto.persistentsubscriptions"; + +import "shared.proto"; + +service PersistentSubscriptions { + rpc Create (CreateReq) returns (CreateResp); + rpc Update (UpdateReq) returns (UpdateResp); + rpc Delete (DeleteReq) returns (DeleteResp); + rpc Read (stream ReadReq) returns (stream ReadResp); + rpc GetInfo (GetInfoReq) returns (GetInfoResp); + rpc ReplayParked (ReplayParkedReq) returns (ReplayParkedResp); + rpc List (ListReq) returns (ListResp); + rpc RestartSubsystem (event_store.client.Empty) returns (event_store.client.Empty); +} + +message ReadReq { + oneof content { + Options options = 1; + Ack ack = 2; + Nack nack = 3; + } + + message Options { + oneof stream_option { + event_store.client.StreamIdentifier stream_identifier = 1; + event_store.client.Empty all = 5; + } + + string group_name = 2; + int32 buffer_size = 3; + UUIDOption uuid_option = 4; + + message UUIDOption { + oneof content { + event_store.client.Empty structured = 1; + event_store.client.Empty string = 2; + } + } + } + + message Ack { + bytes id = 1; + repeated event_store.client.UUID ids = 2; + } + + message Nack { + bytes id = 1; + repeated event_store.client.UUID ids = 2; + Action action = 3; + string reason = 4; + + enum Action { + Unknown = 0; + Park = 1; + Retry = 2; + Skip = 3; + Stop = 4; + } + } +} + +message ReadResp { + oneof content { + ReadEvent event = 1; + SubscriptionConfirmation subscription_confirmation = 2; + } + message ReadEvent { + RecordedEvent event = 1; + RecordedEvent link = 2; + oneof position { + uint64 commit_position = 3; + event_store.client.Empty no_position = 4; + } + oneof count { + int32 retry_count = 5; + event_store.client.Empty no_retry_count = 6; + } + message RecordedEvent { + event_store.client.UUID id = 1; + event_store.client.StreamIdentifier stream_identifier = 2; + uint64 stream_revision = 3; + uint64 prepare_position = 4; + uint64 commit_position = 5; + map metadata = 6; + bytes custom_metadata = 7; + bytes data = 8; + } + } + message SubscriptionConfirmation { + string subscription_id = 1; + } +} + +message CreateReq { + Options options = 1; + + message Options { + oneof stream_option { + StreamOptions stream = 4; + AllOptions all = 5; + } + event_store.client.StreamIdentifier stream_identifier = 1 [deprecated=true]; + string group_name = 2; + Settings settings = 3; + } + + message StreamOptions { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof revision_option { + uint64 revision = 2; + event_store.client.Empty start = 3; + event_store.client.Empty end = 4; + } + } + + message AllOptions { + oneof all_option { + Position position = 1; + event_store.client.Empty start = 2; + event_store.client.Empty end = 3; + } + oneof filter_option { + FilterOptions filter = 4; + event_store.client.Empty no_filter = 5; + } + message FilterOptions { + oneof filter { + Expression stream_identifier = 1; + Expression event_type = 2; + } + oneof window { + uint32 max = 3; + event_store.client.Empty count = 4; + } + uint32 checkpointIntervalMultiplier = 5; + + message Expression { + string regex = 1; + repeated string prefix = 2; + } + } + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + + message Settings { + bool resolve_links = 1; + uint64 revision = 2 [deprecated = true]; + bool extra_statistics = 3; + int32 max_retry_count = 5; + int32 min_checkpoint_count = 7; + int32 max_checkpoint_count = 8; + int32 max_subscriber_count = 9; + int32 live_buffer_size = 10; + int32 read_batch_size = 11; + int32 history_buffer_size = 12; + ConsumerStrategy named_consumer_strategy = 13 [deprecated = true]; + oneof message_timeout { + int64 message_timeout_ticks = 4; + int32 message_timeout_ms = 14; + } + oneof checkpoint_after { + int64 checkpoint_after_ticks = 6; + int32 checkpoint_after_ms = 15; + } + string consumer_strategy = 16; + } + + enum ConsumerStrategy { + DispatchToSingle = 0; + RoundRobin = 1; + Pinned = 2; + } +} + +message CreateResp { +} + +message UpdateReq { + Options options = 1; + + message Options { + oneof stream_option { + StreamOptions stream = 4; + AllOptions all = 5; + } + event_store.client.StreamIdentifier stream_identifier = 1 [deprecated = true]; + string group_name = 2; + Settings settings = 3; + } + + message StreamOptions { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof revision_option { + uint64 revision = 2; + event_store.client.Empty start = 3; + event_store.client.Empty end = 4; + } + } + + message AllOptions { + oneof all_option { + Position position = 1; + event_store.client.Empty start = 2; + event_store.client.Empty end = 3; + } + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + + message Settings { + bool resolve_links = 1; + uint64 revision = 2 [deprecated = true]; + bool extra_statistics = 3; + int32 max_retry_count = 5; + int32 min_checkpoint_count = 7; + int32 max_checkpoint_count = 8; + int32 max_subscriber_count = 9; + int32 live_buffer_size = 10; + int32 read_batch_size = 11; + int32 history_buffer_size = 12; + ConsumerStrategy named_consumer_strategy = 13; + oneof message_timeout { + int64 message_timeout_ticks = 4; + int32 message_timeout_ms = 14; + } + oneof checkpoint_after { + int64 checkpoint_after_ticks = 6; + int32 checkpoint_after_ms = 15; + } + } + + enum ConsumerStrategy { + DispatchToSingle = 0; + RoundRobin = 1; + Pinned = 2; + } +} + +message UpdateResp { +} + +message DeleteReq { + Options options = 1; + + message Options { + oneof stream_option { + event_store.client.StreamIdentifier stream_identifier = 1; + event_store.client.Empty all = 3; + } + + string group_name = 2; + } +} + +message DeleteResp { +} + +message GetInfoReq { + Options options = 1; + + message Options { + oneof stream_option { + event_store.client.StreamIdentifier stream_identifier = 1; + event_store.client.Empty all = 2; + } + + string group_name = 3; + } +} + +message GetInfoResp { + SubscriptionInfo subscription_info = 1; +} + +message SubscriptionInfo { + string event_source = 1; + string group_name = 2; + string status = 3; + repeated ConnectionInfo connections = 4; + int32 average_per_second = 5; + int64 total_items = 6; + int64 count_since_last_measurement = 7; + string last_checkpointed_event_position = 8; + string last_known_event_position = 9; + bool resolve_link_tos = 10; + string start_from = 11; + int32 message_timeout_milliseconds = 12; + bool extra_statistics = 13; + int32 max_retry_count = 14; + int32 live_buffer_size = 15; + int32 buffer_size = 16; + int32 read_batch_size = 17; + int32 check_point_after_milliseconds = 18; + int32 min_check_point_count = 19; + int32 max_check_point_count = 20; + int32 read_buffer_count = 21; + int64 live_buffer_count = 22; + int32 retry_buffer_count = 23; + int32 total_in_flight_messages = 24; + int32 outstanding_messages_count = 25; + string named_consumer_strategy = 26; + int32 max_subscriber_count = 27; + int64 parked_message_count = 28; + + message ConnectionInfo { + string from = 1; + string username = 2; + int32 average_items_per_second = 3; + int64 total_items = 4; + int64 count_since_last_measurement = 5; + repeated Measurement observed_measurements = 6; + int32 available_slots = 7; + int32 in_flight_messages = 8; + string connection_name = 9; + } + + message Measurement { + string key = 1; + int64 value = 2; + } +} + +message ReplayParkedReq { + Options options = 1; + + message Options { + string group_name = 1; + oneof stream_option { + event_store.client.StreamIdentifier stream_identifier = 2; + event_store.client.Empty all = 3; + } + oneof stop_at_option { + int64 stop_at = 4; + event_store.client.Empty no_limit = 5; + } + } +} + +message ReplayParkedResp { +} + +message ListReq { + Options options = 1; + + message Options { + oneof list_option { + event_store.client.Empty list_all_subscriptions = 1; + StreamOption list_for_stream = 2; + } + } + message StreamOption { + oneof stream_option { + event_store.client.StreamIdentifier stream = 1; + event_store.client.Empty all = 2; + } + } +} + +message ListResp { + repeated SubscriptionInfo subscriptions = 1; +} diff --git a/src/Kurrent.Client/Core/protos/projectionmanagement.proto b/src/Kurrent.Client/Core/protos/projectionmanagement.proto new file mode 100644 index 000000000..f16877012 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/projectionmanagement.proto @@ -0,0 +1,174 @@ +syntax = "proto3"; +package event_store.client.projections; +option java_package = "io.kurrent.client.projections"; + +import "google/protobuf/struct.proto"; +import "shared.proto"; + +service Projections { + rpc Create (CreateReq) returns (CreateResp); + rpc Update (UpdateReq) returns (UpdateResp); + rpc Delete (DeleteReq) returns (DeleteResp); + rpc Statistics (StatisticsReq) returns (stream StatisticsResp); + rpc Disable (DisableReq) returns (DisableResp); + rpc Enable (EnableReq) returns (EnableResp); + rpc Reset (ResetReq) returns (ResetResp); + rpc State (StateReq) returns (StateResp); + rpc Result (ResultReq) returns (ResultResp); + rpc RestartSubsystem (Empty) returns (Empty); +} + +message CreateReq { + Options options = 1; + + message Options { + oneof mode { + event_store.client.Empty one_time = 1; + Transient transient = 2; + Continuous continuous = 3; + } + string query = 4; + + message Transient { + string name = 1; + } + message Continuous { + string name = 1; + bool track_emitted_streams = 2; + } + } +} + +message CreateResp { +} + +message UpdateReq { + Options options = 1; + + message Options { + string name = 1; + string query = 2; + oneof emit_option { + bool emit_enabled = 3; + event_store.client.Empty no_emit_options = 4; + } + } +} + +message UpdateResp { +} + +message DeleteReq { + Options options = 1; + + message Options { + string name = 1; + bool delete_emitted_streams = 2; + bool delete_state_stream = 3; + bool delete_checkpoint_stream = 4; + } +} + +message DeleteResp { +} + +message StatisticsReq { + Options options = 1; + message Options { + oneof mode { + string name = 1; + event_store.client.Empty all = 2; + event_store.client.Empty transient = 3; + event_store.client.Empty continuous = 4; + event_store.client.Empty one_time = 5; + } + } +} + +message StatisticsResp { + Details details = 1; + + message Details { + int64 coreProcessingTime = 1; + int64 version = 2; + int64 epoch = 3; + string effectiveName = 4; + int32 writesInProgress = 5; + int32 readsInProgress = 6; + int32 partitionsCached = 7; + string status = 8; + string stateReason = 9; + string name = 10; + string mode = 11; + string position = 12; + float progress = 13; + string lastCheckpoint = 14; + int64 eventsProcessedAfterRestart = 15; + string checkpointStatus = 16; + int64 bufferedEvents = 17; + int32 writePendingEventsBeforeCheckpoint = 18; + int32 writePendingEventsAfterCheckpoint = 19; + } +} + +message StateReq { + Options options = 1; + + message Options { + string name = 1; + string partition = 2; + } +} + +message StateResp { + google.protobuf.Value state = 1; +} + +message ResultReq { + Options options = 1; + + message Options { + string name = 1; + string partition = 2; + } +} + +message ResultResp { + google.protobuf.Value result = 1; +} + +message ResetReq { + Options options = 1; + + message Options { + string name = 1; + bool write_checkpoint = 2; + } +} + +message ResetResp { +} + + +message EnableReq { + Options options = 1; + + message Options { + string name = 1; + } +} + +message EnableResp { +} + +message DisableReq { + Options options = 1; + + message Options { + string name = 1; + bool write_checkpoint = 2; + } +} + +message DisableResp { +} diff --git a/src/Kurrent.Client/Core/protos/serverfeatures.proto b/src/Kurrent.Client/Core/protos/serverfeatures.proto new file mode 100644 index 000000000..61c4ab773 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/serverfeatures.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; +package event_store.client.server_features; +option java_package = "io.kurrent.dbclient.proto.serverfeatures"; +import "shared.proto"; + +service ServerFeatures { + rpc GetSupportedMethods (event_store.client.Empty) returns (SupportedMethods); +} + +message SupportedMethods { + repeated SupportedMethod methods = 1; + string event_store_server_version = 2; +} + +message SupportedMethod { + string method_name = 1; + string service_name = 2; + repeated string features = 3; +} diff --git a/src/Kurrent.Client/Core/protos/shared.proto b/src/Kurrent.Client/Core/protos/shared.proto new file mode 100644 index 000000000..24780afc3 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/shared.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; +package event_store.client; +option java_package = "io.kurrent.dbclient.proto.shared"; +import "google/protobuf/empty.proto"; + +message UUID { + oneof value { + Structured structured = 1; + string string = 2; + } + + message Structured { + int64 most_significant_bits = 1; + int64 least_significant_bits = 2; + } +} +message Empty { +} + +message StreamIdentifier { + reserved 1 to 2; + bytes stream_name = 3; +} + +message AllStreamPosition { + uint64 commit_position = 1; + uint64 prepare_position = 2; +} + +message WrongExpectedVersion { + oneof current_stream_revision_option { + uint64 current_stream_revision = 1; + google.protobuf.Empty current_no_stream = 2; + } + oneof expected_stream_position_option { + uint64 expected_stream_position = 3; + google.protobuf.Empty expected_any = 4; + google.protobuf.Empty expected_stream_exists = 5; + google.protobuf.Empty expected_no_stream = 6; + } +} + +message AccessDenied {} + +message StreamDeleted { + StreamIdentifier stream_identifier = 1; +} + +message Timeout {} + +message Unknown {} + +message InvalidTransaction {} + +message MaximumAppendSizeExceeded { + uint32 maxAppendSize = 1; +} + +message BadRequest { + string message = 1; +} diff --git a/src/Kurrent.Client/Core/protos/status.proto b/src/Kurrent.Client/Core/protos/status.proto new file mode 100644 index 000000000..65eced268 --- /dev/null +++ b/src/Kurrent.Client/Core/protos/status.proto @@ -0,0 +1,48 @@ +// Copyright 2020 Google LLC +// +// 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. + +syntax = "proto3"; + +package google.rpc; + +import "google/protobuf/any.proto"; +import "code.proto"; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/rpc/status;status"; +option java_multiple_files = true; +option java_outer_classname = "StatusProto"; +option java_package = "com.google.rpc"; +option objc_class_prefix = "RPC"; + +// The `Status` type defines a logical error model that is suitable for +// different programming environments, including REST APIs and RPC APIs. It is +// used by [gRPC](https://github.com/grpc). Each `Status` message contains +// three pieces of data: error code, error message, and error details. +// +// You can find out more about this error model and how to work with it in the +// [API Design Guide](https://cloud.google.com/apis/design/errors). +message Status { + // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. + google.rpc.Code code = 1; + + // A developer-facing error message, which should be in English. Any + // user-facing error message should be localized and sent in the + // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client. + string message = 2; + + // A list of messages that carry the error details. There is a common set of + // message types for APIs to use. + google.protobuf.Any details = 3; +} diff --git a/src/Kurrent.Client/Core/protos/streams.proto b/src/Kurrent.Client/Core/protos/streams.proto new file mode 100644 index 000000000..0eb05295c --- /dev/null +++ b/src/Kurrent.Client/Core/protos/streams.proto @@ -0,0 +1,316 @@ +syntax = "proto3"; +package event_store.client.streams; +option java_package = "io.kurrent.dbclient.proto.streams"; + +import "shared.proto"; +import "status.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +service Streams { + rpc Read (ReadReq) returns (stream ReadResp); + rpc Append (stream AppendReq) returns (AppendResp); + rpc Delete (DeleteReq) returns (DeleteResp); + rpc Tombstone (TombstoneReq) returns (TombstoneResp); + rpc BatchAppend (stream BatchAppendReq) returns (stream BatchAppendResp); +} + +message ReadReq { + Options options = 1; + + message Options { + oneof stream_option { + StreamOptions stream = 1; + AllOptions all = 2; + } + ReadDirection read_direction = 3; + bool resolve_links = 4; + oneof count_option { + uint64 count = 5; + SubscriptionOptions subscription = 6; + } + oneof filter_option { + FilterOptions filter = 7; + event_store.client.Empty no_filter = 8; + } + UUIDOption uuid_option = 9; + ControlOption control_option = 10; + + enum ReadDirection { + Forwards = 0; + Backwards = 1; + } + message StreamOptions { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof revision_option { + uint64 revision = 2; + event_store.client.Empty start = 3; + event_store.client.Empty end = 4; + } + } + message AllOptions { + oneof all_option { + Position position = 1; + event_store.client.Empty start = 2; + event_store.client.Empty end = 3; + } + } + message SubscriptionOptions { + } + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + message FilterOptions { + oneof filter { + Expression stream_identifier = 1; + Expression event_type = 2; + } + oneof window { + uint32 max = 3; + event_store.client.Empty count = 4; + } + uint32 checkpointIntervalMultiplier = 5; + + message Expression { + string regex = 1; + repeated string prefix = 2; + } + } + message UUIDOption { + oneof content { + event_store.client.Empty structured = 1; + event_store.client.Empty string = 2; + } + } + message ControlOption { + uint32 compatibility = 1; + } + } +} + +message ReadResp { + oneof content { + ReadEvent event = 1; + SubscriptionConfirmation confirmation = 2; + Checkpoint checkpoint = 3; + StreamNotFound stream_not_found = 4; + uint64 first_stream_position = 5; + uint64 last_stream_position = 6; + AllStreamPosition last_all_stream_position = 7; + CaughtUp caught_up = 8; + FellBehind fell_behind = 9; + } + + message CaughtUp {} + + message FellBehind {} + + message ReadEvent { + RecordedEvent event = 1; + RecordedEvent link = 2; + oneof position { + uint64 commit_position = 3; + event_store.client.Empty no_position = 4; + } + + message RecordedEvent { + event_store.client.UUID id = 1; + event_store.client.StreamIdentifier stream_identifier = 2; + uint64 stream_revision = 3; + uint64 prepare_position = 4; + uint64 commit_position = 5; + map metadata = 6; + bytes custom_metadata = 7; + bytes data = 8; + } + } + message SubscriptionConfirmation { + string subscription_id = 1; + } + message Checkpoint { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + message StreamNotFound { + event_store.client.StreamIdentifier stream_identifier = 1; + } +} + +message AppendReq { + oneof content { + Options options = 1; + ProposedMessage proposed_message = 2; + } + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_revision { + uint64 revision = 2; + event_store.client.Empty no_stream = 3; + event_store.client.Empty any = 4; + event_store.client.Empty stream_exists = 5; + } + } + message ProposedMessage { + event_store.client.UUID id = 1; + map metadata = 2; + bytes custom_metadata = 3; + bytes data = 4; + } +} + +message AppendResp { + oneof result { + Success success = 1; + WrongExpectedVersion wrong_expected_version = 2; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + + message Success { + oneof current_revision_option { + uint64 current_revision = 1; + event_store.client.Empty no_stream = 2; + } + oneof position_option { + Position position = 3; + event_store.client.Empty no_position = 4; + } + } + + message WrongExpectedVersion { + oneof current_revision_option_20_6_0 { + uint64 current_revision_20_6_0 = 1; + event_store.client.Empty no_stream_20_6_0 = 2; + } + oneof expected_revision_option_20_6_0 { + uint64 expected_revision_20_6_0 = 3; + event_store.client.Empty any_20_6_0 = 4; + event_store.client.Empty stream_exists_20_6_0 = 5; + } + oneof current_revision_option { + uint64 current_revision = 6; + event_store.client.Empty current_no_stream = 7; + } + oneof expected_revision_option { + uint64 expected_revision = 8; + event_store.client.Empty expected_any = 9; + event_store.client.Empty expected_stream_exists = 10; + event_store.client.Empty expected_no_stream = 11; + } + + } +} + +message BatchAppendReq { + event_store.client.UUID correlation_id = 1; + Options options = 2; + repeated ProposedMessage proposed_messages = 3; + bool is_final = 4; + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_position { + uint64 stream_position = 2; + google.protobuf.Empty no_stream = 3; + google.protobuf.Empty any = 4; + google.protobuf.Empty stream_exists = 5; + } + oneof deadline_option { + google.protobuf.Timestamp deadline_21_10_0 = 6; + google.protobuf.Duration deadline = 7; + } + } + + message ProposedMessage { + event_store.client.UUID id = 1; + map metadata = 2; + bytes custom_metadata = 3; + bytes data = 4; + } +} + +message BatchAppendResp { + event_store.client.UUID correlation_id = 1; + oneof result { + google.rpc.Status error = 2; + Success success = 3; + } + + event_store.client.StreamIdentifier stream_identifier = 4; + + oneof expected_stream_position { + uint64 stream_position = 5; + google.protobuf.Empty no_stream = 6; + google.protobuf.Empty any = 7; + google.protobuf.Empty stream_exists = 8; + } + + message Success { + oneof current_revision_option { + uint64 current_revision = 1; + google.protobuf.Empty no_stream = 2; + } + oneof position_option { + event_store.client.AllStreamPosition position = 3; + google.protobuf.Empty no_position = 4; + } + } +} + +message DeleteReq { + Options options = 1; + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_revision { + uint64 revision = 2; + event_store.client.Empty no_stream = 3; + event_store.client.Empty any = 4; + event_store.client.Empty stream_exists = 5; + } + } +} + +message DeleteResp { + oneof position_option { + Position position = 1; + event_store.client.Empty no_position = 2; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } +} + +message TombstoneReq { + Options options = 1; + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_revision { + uint64 revision = 2; + event_store.client.Empty no_stream = 3; + event_store.client.Empty any = 4; + event_store.client.Empty stream_exists = 5; + } + } +} + +message TombstoneResp { + oneof position_option { + Position position = 1; + event_store.client.Empty no_position = 2; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } +} diff --git a/src/Kurrent.Client/Core/protos/usermanagement.proto b/src/Kurrent.Client/Core/protos/usermanagement.proto new file mode 100644 index 000000000..4b55251fd --- /dev/null +++ b/src/Kurrent.Client/Core/protos/usermanagement.proto @@ -0,0 +1,119 @@ +syntax = "proto3"; +package event_store.client.users; +option java_package = "io.kurrent.client.users"; + +service Users { + rpc Create (CreateReq) returns (CreateResp); + rpc Update (UpdateReq) returns (UpdateResp); + rpc Delete (DeleteReq) returns (DeleteResp); + rpc Disable (DisableReq) returns (DisableResp); + rpc Enable (EnableReq) returns (EnableResp); + rpc Details (DetailsReq) returns (stream DetailsResp); + rpc ChangePassword (ChangePasswordReq) returns (ChangePasswordResp); + rpc ResetPassword (ResetPasswordReq) returns (ResetPasswordResp); +} + +message CreateReq { + Options options = 1; + message Options { + string login_name = 1; + string password = 2; + string full_name = 3; + repeated string groups = 4; + } +} + +message CreateResp { + +} + +message UpdateReq { + Options options = 1; + message Options { + string login_name = 1; + string password = 2; + string full_name = 3; + repeated string groups = 4; + } +} + +message UpdateResp { + +} + +message DeleteReq { + Options options = 1; + message Options { + string login_name = 1; + } +} + +message DeleteResp { + +} + +message EnableReq { + Options options = 1; + message Options { + string login_name = 1; + } +} + +message EnableResp { + +} + +message DisableReq { + Options options = 1; + message Options { + string login_name = 1; + } +} + +message DisableResp { +} + +message DetailsReq { + Options options = 1; + message Options { + string login_name = 1; + } +} + +message DetailsResp { + UserDetails user_details = 1; + message UserDetails { + string login_name = 1; + string full_name = 2; + repeated string groups = 3; + DateTime last_updated = 4; + bool disabled = 5; + + message DateTime { + int64 ticks_since_epoch = 1; + } + } +} + +message ChangePasswordReq { + Options options = 1; + message Options { + string login_name = 1; + string current_password = 2; + string new_password = 3; + } +} + +message ChangePasswordResp { +} + +message ResetPasswordReq { + Options options = 1; + message Options { + string login_name = 1; + string new_password = 2; + } +} + +message ResetPasswordResp { +} diff --git a/src/Kurrent.Client/Kurrent.Client.csproj b/src/Kurrent.Client/Kurrent.Client.csproj new file mode 100644 index 000000000..e6652b773 --- /dev/null +++ b/src/Kurrent.Client/Kurrent.Client.csproj @@ -0,0 +1,112 @@ + + + + Kurrent.Client + The base GRPC client library for the Kurrent Platform. Get the open source or commercial versions of KurrentDB from https://kurrent.io/ + Kurrent.Client + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Kurrent.Client/OpenTelemetry/TracerProviderBuilderExtensions.cs b/src/Kurrent.Client/OpenTelemetry/TracerProviderBuilderExtensions.cs new file mode 100644 index 000000000..dd1478b18 --- /dev/null +++ b/src/Kurrent.Client/OpenTelemetry/TracerProviderBuilderExtensions.cs @@ -0,0 +1,19 @@ +using EventStore.Client.Diagnostics; +using JetBrains.Annotations; +using OpenTelemetry.Trace; + +namespace EventStore.Client.Extensions.OpenTelemetry; + +/// +/// Extension methods used to facilitate tracing instrumentation of the EventStore Client. +/// +[PublicAPI] +public static class TracerProviderBuilderExtensions { + /// + /// Adds the EventStore client ActivitySource name to the list of subscribed sources on the + /// + /// being configured. + /// The instance of to chain configuration. + public static TracerProviderBuilder AddEventStoreClientInstrumentation(this TracerProviderBuilder builder) => + builder.AddSource(KurrentClientDiagnostics.InstrumentationName); +} diff --git a/src/Kurrent.Client/Operations/DatabaseScavengeResult.cs b/src/Kurrent.Client/Operations/DatabaseScavengeResult.cs new file mode 100644 index 000000000..9a09143fa --- /dev/null +++ b/src/Kurrent.Client/Operations/DatabaseScavengeResult.cs @@ -0,0 +1,81 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure representing the result of a scavenge operation. + /// + public readonly struct DatabaseScavengeResult : IEquatable { + /// + /// The ID of the scavenge operation. + /// + public string ScavengeId { get; } + + /// + /// The of the scavenge operation. + /// + public ScavengeResult Result { get; } + + /// + /// A scavenge operation that has started. + /// + /// + /// + public static DatabaseScavengeResult Started(string scavengeId) => + new DatabaseScavengeResult(scavengeId, ScavengeResult.Started); + + /// + /// A scavenge operation that has stopped. + /// + /// + /// + public static DatabaseScavengeResult Stopped(string scavengeId) => + new DatabaseScavengeResult(scavengeId, ScavengeResult.Stopped); + + /// + /// A scavenge operation that is currently in progress. + /// + /// + /// + public static DatabaseScavengeResult InProgress(string scavengeId) => + new DatabaseScavengeResult(scavengeId, ScavengeResult.InProgress); + + /// + /// A scavenge operation whose state is unknown. + /// + /// + /// + public static DatabaseScavengeResult Unknown(string scavengeId) => + new DatabaseScavengeResult(scavengeId, ScavengeResult.Unknown); + + private DatabaseScavengeResult(string scavengeId, ScavengeResult result) { + ScavengeId = scavengeId; + Result = result; + } + + /// + public bool Equals(DatabaseScavengeResult other) => ScavengeId == other.ScavengeId && Result == other.Result; + + /// + public override bool Equals(object? obj) => obj is DatabaseScavengeResult other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(ScavengeId).Combine(Result); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(DatabaseScavengeResult left, DatabaseScavengeResult right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(DatabaseScavengeResult left, DatabaseScavengeResult right) => + !left.Equals(right); + } +} diff --git a/src/Kurrent.Client/Operations/KurrentOperationsClient.Admin.cs b/src/Kurrent.Client/Operations/KurrentOperationsClient.Admin.cs new file mode 100644 index 000000000..368fdca13 --- /dev/null +++ b/src/Kurrent.Client/Operations/KurrentOperationsClient.Admin.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Operations; + +namespace EventStore.Client { + public partial class KurrentOperationsClient { + private static readonly Empty EmptyResult = new Empty(); + + /// + /// Shuts down the KurrentDB node. + /// + /// + /// + /// + /// + public async Task ShutdownAsync( + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).ShutdownAsync(EmptyResult, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Initiates an index merge operation. + /// + /// + /// + /// + /// + public async Task MergeIndexesAsync( + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).MergeIndexesAsync(EmptyResult, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Resigns a node. + /// + /// + /// + /// + /// + public async Task ResignNodeAsync( + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).ResignNodeAsync(EmptyResult, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Sets the node priority. + /// + /// + /// + /// + /// + /// + public async Task SetNodePriorityAsync(int nodePriority, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).SetNodePriorityAsync( + new SetNodePriorityReq {Priority = nodePriority}, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Restart persistent subscriptions + /// + /// + /// + /// + /// + public async Task RestartPersistentSubscriptions( + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).RestartPersistentSubscriptionsAsync( + EmptyResult, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/Operations/KurrentOperationsClient.Scavenge.cs b/src/Kurrent.Client/Operations/KurrentOperationsClient.Scavenge.cs new file mode 100644 index 000000000..eb2c88c54 --- /dev/null +++ b/src/Kurrent.Client/Operations/KurrentOperationsClient.Scavenge.cs @@ -0,0 +1,82 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Operations; + +namespace EventStore.Client { + public partial class KurrentOperationsClient { + /// + /// Starts a scavenge operation. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task StartScavengeAsync( + int threadCount = 1, + int startFromChunk = 0, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (threadCount <= 0) { + throw new ArgumentOutOfRangeException(nameof(threadCount)); + } + + if (startFromChunk < 0) { + throw new ArgumentOutOfRangeException(nameof(startFromChunk)); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).StartScavengeAsync( + new StartScavengeReq { + Options = new StartScavengeReq.Types.Options { + ThreadCount = threadCount, + StartFromChunk = startFromChunk + } + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + var result = await call.ResponseAsync.ConfigureAwait(false); + + return result.ScavengeResult switch { + ScavengeResp.Types.ScavengeResult.Started => DatabaseScavengeResult.Started(result.ScavengeId), + ScavengeResp.Types.ScavengeResult.Stopped => DatabaseScavengeResult.Stopped(result.ScavengeId), + ScavengeResp.Types.ScavengeResult.InProgress => DatabaseScavengeResult.InProgress(result.ScavengeId), + _ => DatabaseScavengeResult.Unknown(result.ScavengeId) + }; + } + + /// + /// Stops a scavenge operation. + /// + /// + /// + /// + /// + /// + public async Task StopScavengeAsync( + string scavengeId, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + var result = await new Operations.Operations.OperationsClient( + channelInfo.CallInvoker).StopScavengeAsync(new StopScavengeReq { + Options = new StopScavengeReq.Types.Options { + ScavengeId = scavengeId + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) + .ResponseAsync.ConfigureAwait(false); + + return result.ScavengeResult switch { + ScavengeResp.Types.ScavengeResult.Started => DatabaseScavengeResult.Started(result.ScavengeId), + ScavengeResp.Types.ScavengeResult.Stopped => DatabaseScavengeResult.Stopped(result.ScavengeId), + ScavengeResp.Types.ScavengeResult.InProgress => DatabaseScavengeResult.InProgress(result.ScavengeId), + _ => DatabaseScavengeResult.Unknown(result.ScavengeId) + }; + } + } +} diff --git a/src/Kurrent.Client/Operations/KurrentOperationsClient.cs b/src/Kurrent.Client/Operations/KurrentOperationsClient.cs new file mode 100644 index 000000000..d392aa578 --- /dev/null +++ b/src/Kurrent.Client/Operations/KurrentOperationsClient.cs @@ -0,0 +1,33 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace EventStore.Client; + +/// +/// The client used to perform maintenance and other administrative tasks on the KurrentDB. +/// +public sealed partial class KurrentOperationsClient : KurrentClientBase { + static readonly Dictionary> ExceptionMap = + new() { + [Constants.Exceptions.ScavengeNotFound] = ex => new ScavengeNotFoundException( + ex.Trailers.FirstOrDefault(x => x.Key == Constants.Exceptions.ScavengeId)?.Value + ) + }; + + readonly ILogger _log; + + /// + /// Constructs a new . This method is not intended to be called directly in your code. + /// + /// + public KurrentOperationsClient(IOptions options) : this(options.Value) { } + + /// + /// Constructs a new . + /// + /// + public KurrentOperationsClient(KurrentClientSettings? settings = null) : base(settings, ExceptionMap) => + _log = Settings.LoggerFactory?.CreateLogger() ?? new NullLogger(); +} diff --git a/src/Kurrent.Client/Operations/KurrentOperationsClientServiceCollectionExtensions.cs b/src/Kurrent.Client/Operations/KurrentOperationsClientServiceCollectionExtensions.cs new file mode 100644 index 000000000..fc3ce9505 --- /dev/null +++ b/src/Kurrent.Client/Operations/KurrentOperationsClientServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +// ReSharper disable CheckNamespace + +using System; +using System.Net.Http; +using EventStore.Client; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.DependencyInjection { + /// + /// A set of extension methods for which provide support for an . + /// + public static class KurrentOperationsClientServiceCollectionExtensions { + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentOperationsClient(this IServiceCollection services, Uri address, + Func? createHttpMessageHandler = null) + => services.AddKurrentOperationsClient(options => { + options.ConnectivitySettings.Address = address; + options.CreateHttpMessageHandler = createHttpMessageHandler; + }); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentOperationsClient(this IServiceCollection services, + Action? configureOptions = null) => + services.AddKurrentOperationsClient(new KurrentClientSettings(), configureOptions); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentOperationsClient(this IServiceCollection services, + string connectionString, Action? configureOptions = null) => + services.AddKurrentOperationsClient(KurrentClientSettings.Create(connectionString), configureOptions); + + private static IServiceCollection AddKurrentOperationsClient(this IServiceCollection services, + KurrentClientSettings options, Action? configureOptions) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + + configureOptions?.Invoke(options); + + services.TryAddSingleton(provider => { + options.LoggerFactory ??= provider.GetService(); + options.Interceptors ??= provider.GetServices(); + + return new KurrentOperationsClient(options); + }); + + return services; + } + } +} +// ReSharper restore CheckNamespace diff --git a/src/Kurrent.Client/Operations/ScavengeResult.cs b/src/Kurrent.Client/Operations/ScavengeResult.cs new file mode 100644 index 000000000..6b8802d85 --- /dev/null +++ b/src/Kurrent.Client/Operations/ScavengeResult.cs @@ -0,0 +1,25 @@ +namespace EventStore.Client { + /// + /// An enumeration that represents the result of a scavenge operation. + /// + public enum ScavengeResult { + /// + /// The scavenge operation has started. + /// + Started, + /// + /// The scavenge operation is in progress. + /// + InProgress, + + /// + /// The scavenge operation has stopped. + /// + Stopped, + + /// + /// The status of the scavenge operation was unknown. + /// + Unknown + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Create.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Create.cs new file mode 100644 index 000000000..80987b3ce --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Create.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.PersistentSubscriptions; + +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + private static readonly IDictionary NamedConsumerStrategyToCreateProto + = new Dictionary { + [SystemConsumerStrategies.DispatchToSingle] = CreateReq.Types.ConsumerStrategy.DispatchToSingle, + [SystemConsumerStrategies.RoundRobin] = CreateReq.Types.ConsumerStrategy.RoundRobin, + [SystemConsumerStrategies.Pinned] = CreateReq.Types.ConsumerStrategy.Pinned, + }; + + private static CreateReq.Types.StreamOptions StreamOptionsForCreateProto(string streamName, StreamPosition position) { + if (position == StreamPosition.Start) { + return new CreateReq.Types.StreamOptions { + StreamIdentifier = streamName, + Start = new Empty() + }; + } + + if (position == StreamPosition.End) { + return new CreateReq.Types.StreamOptions { + StreamIdentifier = streamName, + End = new Empty() + }; + } + + return new CreateReq.Types.StreamOptions { + StreamIdentifier = streamName, + Revision = position.ToUInt64() + }; + } + + private static CreateReq.Types.AllOptions AllOptionsForCreateProto(Position position, IEventFilter? filter) { + var allFilter = GetFilterOptions(filter); + CreateReq.Types.AllOptions allOptions; + if (position == Position.Start) { + allOptions = new CreateReq.Types.AllOptions { + Start = new Empty(), + }; + } else if (position == Position.End) { + allOptions = new CreateReq.Types.AllOptions { + End = new Empty() + }; + } else { + allOptions = new CreateReq.Types.AllOptions { + Position = new CreateReq.Types.Position { + CommitPosition = position.CommitPosition, + PreparePosition = position.PreparePosition + } + }; + } + + if (allFilter is null) { + allOptions.NoFilter = new Empty(); + } else { + allOptions.Filter = allFilter; + } + + return allOptions; + } + + private static CreateReq.Types.AllOptions.Types.FilterOptions? GetFilterOptions(IEventFilter? filter) { + if (filter == null) { + return null; + } + + var options = filter switch { + StreamFilter _ => new CreateReq.Types.AllOptions.Types.FilterOptions { + StreamIdentifier = (filter.Prefixes, filter.Regex) switch { + (PrefixFilterExpression[] _, RegularFilterExpression _) + when (filter.Prefixes?.Length ?? 0) == 0 && + filter.Regex != RegularFilterExpression.None => + new CreateReq.Types.AllOptions.Types.FilterOptions.Types.Expression + {Regex = filter.Regex}, + (PrefixFilterExpression[] _, RegularFilterExpression _) + when (filter.Prefixes?.Length ?? 0) != 0 && + filter.Regex == RegularFilterExpression.None => + new CreateReq.Types.AllOptions.Types.FilterOptions.Types.Expression { + Prefix = {Array.ConvertAll(filter.Prefixes!, e => e.ToString())} + }, + _ => throw new InvalidOperationException() + } + }, + EventTypeFilter _ => new CreateReq.Types.AllOptions.Types.FilterOptions { + EventType = (filter.Prefixes, filter.Regex) switch { + (PrefixFilterExpression[] _, RegularFilterExpression _) + when (filter.Prefixes?.Length ?? 0) == 0 && + filter.Regex != RegularFilterExpression.None => + new CreateReq.Types.AllOptions.Types.FilterOptions.Types.Expression + {Regex = filter.Regex}, + (PrefixFilterExpression[] _, RegularFilterExpression _) + when (filter.Prefixes?.Length ?? 0) != 0 && + filter.Regex == RegularFilterExpression.None => + new CreateReq.Types.AllOptions.Types.FilterOptions.Types.Expression { + Prefix = {Array.ConvertAll(filter.Prefixes!, e => e.ToString())} + }, + _ => throw new InvalidOperationException() + } + }, + _ => throw new InvalidOperationException() + }; + + if (filter.MaxSearchWindow.HasValue) { + options.Max = filter.MaxSearchWindow.Value; + } else { + options.Count = new Empty(); + } + + return options; + } + + + /// + /// Creates a persistent subscription. + /// + /// + public async Task CreateToStreamAsync(string streamName, string groupName, PersistentSubscriptionSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + await CreateInternalAsync(streamName, groupName, null, settings, deadline, userCredentials, + cancellationToken) + .ConfigureAwait(false); + + /// + /// Creates a persistent subscription. + /// + /// + [Obsolete("CreateAsync is no longer supported. Use CreateToStreamAsync instead.", false)] + public async Task CreateAsync(string streamName, string groupName, PersistentSubscriptionSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + await CreateInternalAsync(streamName, groupName, null, settings, deadline, userCredentials, + cancellationToken) + .ConfigureAwait(false); + + /// + /// Creates a filtered persistent subscription to $all. + /// + public async Task CreateToAllAsync(string groupName, IEventFilter eventFilter, + PersistentSubscriptionSettings settings, TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + await CreateInternalAsync(SystemStreams.AllStream, groupName, eventFilter, settings, deadline, + userCredentials, cancellationToken) + .ConfigureAwait(false); + + /// + /// Creates a persistent subscription to $all. + /// + public async Task CreateToAllAsync(string groupName, PersistentSubscriptionSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + await CreateInternalAsync(SystemStreams.AllStream, groupName, null, settings, deadline, userCredentials, + cancellationToken) + .ConfigureAwait(false); + + private async Task CreateInternalAsync(string streamName, string groupName, IEventFilter? eventFilter, + PersistentSubscriptionSettings settings, TimeSpan? deadline, UserCredentials? userCredentials, + CancellationToken cancellationToken) { + if (streamName is null) { + throw new ArgumentNullException(nameof(streamName)); + } + + if (groupName is null) { + throw new ArgumentNullException(nameof(groupName)); + } + + if (settings is null) { + throw new ArgumentNullException(nameof(settings)); + } + + if (settings.ConsumerStrategyName is null) { + throw new ArgumentNullException(nameof(settings.ConsumerStrategyName)); + } + + if (streamName != SystemStreams.AllStream && settings.StartFrom != null && + settings.StartFrom is not StreamPosition) { + throw new ArgumentException( + $"{nameof(settings.StartFrom)} must be of type '{nameof(StreamPosition)}' when subscribing to a stream"); + } + + if (streamName == SystemStreams.AllStream && settings.StartFrom != null && + settings.StartFrom is not Position) { + throw new ArgumentException( + $"{nameof(settings.StartFrom)} must be of type '{nameof(Position)}' when subscribing to {SystemStreams.AllStream}"); + } + + if (eventFilter != null && streamName != SystemStreams.AllStream) { + throw new ArgumentException( + $"Filters are only supported when subscribing to {SystemStreams.AllStream}"); + } + + if (!NamedConsumerStrategyToCreateProto.ContainsKey(settings.ConsumerStrategyName)) { + throw new ArgumentException( + "The specified consumer strategy is not supported, specify one of the SystemConsumerStrategies"); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + + if (streamName == SystemStreams.AllStream && + !channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsToAll) { + throw new InvalidOperationException("The server does not support persistent subscriptions to $all."); + } + + using var call = new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient( + channelInfo.CallInvoker).CreateAsync(new CreateReq { + Options = new CreateReq.Types.Options { + Stream = streamName != SystemStreams.AllStream + ? StreamOptionsForCreateProto(streamName, + (StreamPosition)(settings.StartFrom ?? StreamPosition.End)) + : null, + All = streamName == SystemStreams.AllStream + ? AllOptionsForCreateProto((Position)(settings.StartFrom ?? Position.End), eventFilter) + : null, +#pragma warning disable 612 + StreamIdentifier = + streamName != SystemStreams.AllStream + ? streamName + : string.Empty, /*for backwards compatibility*/ +#pragma warning restore 612 + GroupName = groupName, + Settings = new CreateReq.Types.Settings { +#pragma warning disable 612 + Revision = streamName != SystemStreams.AllStream + ? ((StreamPosition)(settings.StartFrom ?? StreamPosition.End)).ToUInt64() + : default, /*for backwards compatibility*/ +#pragma warning restore 612 + CheckpointAfterMs = (int)settings.CheckPointAfter.TotalMilliseconds, + ExtraStatistics = settings.ExtraStatistics, + MessageTimeoutMs = (int)settings.MessageTimeout.TotalMilliseconds, + ResolveLinks = settings.ResolveLinkTos, + HistoryBufferSize = settings.HistoryBufferSize, + LiveBufferSize = settings.LiveBufferSize, + MaxCheckpointCount = settings.CheckPointUpperBound, + MaxRetryCount = settings.MaxRetryCount, + MaxSubscriberCount = settings.MaxSubscriberCount, + MinCheckpointCount = settings.CheckPointLowerBound, +#pragma warning disable 612 + /*for backwards compatibility*/ + NamedConsumerStrategy = NamedConsumerStrategyToCreateProto[settings.ConsumerStrategyName], +#pragma warning restore 612 + ReadBatchSize = settings.ReadBatchSize + } + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Delete.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Delete.cs new file mode 100644 index 000000000..d84c0e61a --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Delete.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.PersistentSubscriptions; + +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + /// + /// Deletes a persistent subscription. + /// + [Obsolete("DeleteAsync is no longer supported. Use DeleteToStreamAsync instead.", false)] + public Task DeleteAsync(string streamName, string groupName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) => + DeleteToStreamAsync(streamName, groupName, deadline, userCredentials, cancellationToken); + + /// + /// Deletes a persistent subscription. + /// + public async Task DeleteToStreamAsync(string streamName, string groupName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + + if (streamName == SystemStreams.AllStream && + !channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsToAll) { + throw new NotSupportedException("The server does not support persistent subscriptions to $all."); + } + + var deleteOptions = new DeleteReq.Types.Options { + GroupName = groupName + }; + + if (streamName == SystemStreams.AllStream) { + deleteOptions.All = new Empty(); + } else { + deleteOptions.StreamIdentifier = streamName; + } + + using var call = + new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient( + channelInfo.CallInvoker) + .DeleteAsync(new DeleteReq {Options = deleteOptions}, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, + cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Deletes a persistent subscription to $all. + /// + public async Task DeleteToAllAsync(string groupName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) => + await DeleteToStreamAsync(SystemStreams.AllStream, groupName, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Info.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Info.cs new file mode 100644 index 000000000..78ececf72 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Info.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.PersistentSubscriptions; +using Grpc.Core; + +#nullable enable +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + /// + /// Gets the status of a persistent subscription to $all + /// + public async Task GetInfoToAllAsync(string groupName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsGetInfo) { + var req = new GetInfoReq() { + Options = new GetInfoReq.Types.Options{ + GroupName = groupName, + All = new Empty() + } + }; + + return await GetInfoGrpcAsync(req, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + } + + throw new NotSupportedException("The server does not support getting persistent subscription details for $all"); + } + + /// + /// Gets the status of a persistent subscription to a stream + /// + public async Task GetInfoToStreamAsync(string streamName, string groupName, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsGetInfo) { + var req = new GetInfoReq() { + Options = new GetInfoReq.Types.Options { + GroupName = groupName, + StreamIdentifier = streamName + } + }; + + return await GetInfoGrpcAsync(req, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + } + + return await GetInfoHttpAsync(streamName, groupName, channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + } + + private async Task GetInfoGrpcAsync(GetInfoReq req, TimeSpan? deadline, + UserCredentials? userCredentials, CallInvoker callInvoker, CancellationToken cancellationToken) { + + var result = await new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient(callInvoker) + .GetInfoAsync(req, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) + .ConfigureAwait(false); + + return PersistentSubscriptionInfo.From(result.SubscriptionInfo); + } + + private async Task GetInfoHttpAsync(string streamName, string groupName, + ChannelInfo channelInfo, TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { + + var path = $"/subscriptions/{UrlEncode(streamName)}/{UrlEncode(groupName)}/info"; + var result = await HttpGet(path, + onNotFound: () => throw new PersistentSubscriptionNotFoundException(streamName, groupName), + channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + + return PersistentSubscriptionInfo.From(result); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.List.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.List.cs new file mode 100644 index 000000000..a2c92b733 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.List.cs @@ -0,0 +1,106 @@ +using EventStore.Client.PersistentSubscriptions; +using Grpc.Core; + +#nullable enable +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + /// + /// Lists persistent subscriptions to $all. + /// + public async Task> ListToAllAsync(TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsList) { + var req = new ListReq() { + Options = new ListReq.Types.Options{ + ListForStream = new ListReq.Types.StreamOption() { + All = new Empty() + } + } + }; + + return await ListGrpcAsync(req, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + } + + throw new NotSupportedException("The server does not support listing the persistent subscriptions."); + } + + /// + /// Lists persistent subscriptions to the specified stream. + /// + public async Task> ListToStreamAsync(string streamName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsList) { + var req = new ListReq() { + Options = new ListReq.Types.Options { + ListForStream = new ListReq.Types.StreamOption() { + Stream = streamName + } + } + }; + + return await ListGrpcAsync(req, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + } + + return await ListHttpAsync(streamName, channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Lists all persistent subscriptions. + /// + public async Task> ListAllAsync(TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsList) { + var req = new ListReq() { + Options = new ListReq.Types.Options { + ListAllSubscriptions = new Empty() + } + }; + + return await ListGrpcAsync(req, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + } + + try { + var result = await HttpGet>("/subscriptions", + onNotFound: () => throw new PersistentSubscriptionNotFoundException(string.Empty, string.Empty), + channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + + return result.Select(PersistentSubscriptionInfo.From); + } catch (AccessDeniedException ex) when (userCredentials != null) { // Required to get same gRPC behavior. + throw new NotAuthenticatedException(ex.Message, ex); + } + } + + private async Task> ListGrpcAsync(ListReq req, TimeSpan? deadline, + UserCredentials? userCredentials, CallInvoker callInvoker, CancellationToken cancellationToken) { + + using var call = new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient(callInvoker) + .ListAsync(req, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + ListResp? response = await call.ResponseAsync.ConfigureAwait(false); + + return response.Subscriptions.Select(PersistentSubscriptionInfo.From); + } + + private async Task> ListHttpAsync(string streamName, + ChannelInfo channelInfo, TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { + + var path = $"/subscriptions/{UrlEncode(streamName)}"; + var result = await HttpGet>(path, + onNotFound: () => throw new PersistentSubscriptionNotFoundException(streamName, string.Empty), + channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + return result.Select(PersistentSubscriptionInfo.From); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Read.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Read.cs new file mode 100644 index 000000000..779413111 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Read.cs @@ -0,0 +1,478 @@ +using System.Threading.Channels; +using EventStore.Client.PersistentSubscriptions; +using EventStore.Client.Diagnostics; +using Grpc.Core; + +using static EventStore.Client.PersistentSubscriptions.PersistentSubscriptions; +using static EventStore.Client.PersistentSubscriptions.ReadResp.ContentOneofCase; + +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + /// + /// Subscribes to a persistent subscription. + /// + /// + /// + /// + [Obsolete("SubscribeAsync is no longer supported. Use SubscribeToStream with manual acks instead.", false)] + public async Task SubscribeAsync( + string streamName, string groupName, + Func eventAppeared, + Action? subscriptionDropped = null, + UserCredentials? userCredentials = null, int bufferSize = 10, bool autoAck = true, + CancellationToken cancellationToken = default + ) { + if (autoAck) { + throw new InvalidOperationException( + $"AutoAck is no longer supported. Please use {nameof(SubscribeToStream)} with manual acks instead." + ); + } + + return await PersistentSubscription + .Confirm( + SubscribeToStream(streamName, groupName, bufferSize, userCredentials, cancellationToken), + eventAppeared, + subscriptionDropped ?? delegate { }, + _log, + userCredentials, + cancellationToken + ) + .ConfigureAwait(false); + } + + /// + /// Subscribes to a persistent subscription. Messages must be manually acknowledged + /// + /// + /// + /// + public async Task SubscribeToStreamAsync( + string streamName, string groupName, + Func eventAppeared, + Action? subscriptionDropped = null, + UserCredentials? userCredentials = null, int bufferSize = 10, + CancellationToken cancellationToken = default + ) { + return await PersistentSubscription + .Confirm( + SubscribeToStream(streamName, groupName, bufferSize, userCredentials, cancellationToken), + eventAppeared, + subscriptionDropped ?? delegate { }, + _log, + userCredentials, + cancellationToken + ) + .ConfigureAwait(false); + } + + /// + /// Subscribes to a persistent subscription. Messages must be manually acknowledged. + /// + /// The name of the stream to read events from. + /// The name of the persistent subscription group. + /// The size of the buffer. + /// The optional user credentials to perform operation with. + /// The optional . + /// + public PersistentSubscriptionResult SubscribeToStream( + string streamName, string groupName, int bufferSize = 10, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default + ) { + if (streamName == null) { + throw new ArgumentNullException(nameof(streamName)); + } + + if (groupName == null) { + throw new ArgumentNullException(nameof(groupName)); + } + + if (streamName == string.Empty) { + throw new ArgumentException($"{nameof(streamName)} may not be empty.", nameof(streamName)); + } + + if (groupName == string.Empty) { + throw new ArgumentException($"{nameof(groupName)} may not be empty.", nameof(groupName)); + } + + if (bufferSize <= 0) { + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + } + + var readOptions = new ReadReq.Types.Options { + BufferSize = bufferSize, + GroupName = groupName, + UuidOption = new ReadReq.Types.Options.Types.UUIDOption { Structured = new Empty() } + }; + + if (streamName == SystemStreams.AllStream) { + readOptions.All = new Empty(); + } else { + readOptions.StreamIdentifier = streamName; + } + + return new PersistentSubscriptionResult( + streamName, + groupName, + async ct => { + var channelInfo = await GetChannelInfo(ct).ConfigureAwait(false); + + if (streamName == SystemStreams.AllStream && + !channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsToAll) { + throw new NotSupportedException( + "The server does not support persistent subscriptions to $all." + ); + } + + return channelInfo; + }, + new() { Options = readOptions }, + Settings, + userCredentials, + cancellationToken + ); + } + + /// + /// Subscribes to a persistent subscription to $all. Messages must be manually acknowledged + /// + public async Task SubscribeToAllAsync( + string groupName, + Func eventAppeared, + Action? subscriptionDropped = null, + UserCredentials? userCredentials = null, int bufferSize = 10, + CancellationToken cancellationToken = default + ) => + await SubscribeToStreamAsync( + SystemStreams.AllStream, + groupName, + eventAppeared, + subscriptionDropped, + userCredentials, + bufferSize, + cancellationToken + ) + .ConfigureAwait(false); + + /// + /// Subscribes to a persistent subscription to $all. Messages must be manually acknowledged. + /// + /// The name of the persistent subscription group. + /// The size of the buffer. + /// The optional user credentials to perform operation with. + /// The optional . + /// + public PersistentSubscriptionResult SubscribeToAll( + string groupName, int bufferSize = 10, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default + ) => + SubscribeToStream(SystemStreams.AllStream, groupName, bufferSize, userCredentials, cancellationToken); + + /// + public class PersistentSubscriptionResult : IAsyncEnumerable, IAsyncDisposable, IDisposable { + const int MaxEventIdLength = 2000; + + readonly ReadReq _request; + readonly Channel _channel; + readonly CancellationTokenSource _cts; + readonly CallOptions _callOptions; + + AsyncDuplexStreamingCall? _call; + int _messagesEnumerated; + + /// + /// The server-generated unique identifier for the subscription. + /// + public string? SubscriptionId { get; private set; } + + /// + /// The name of the stream to read events from. + /// + public string StreamName { get; } + + /// + /// The name of the persistent subscription group. + /// + public string GroupName { get; } + + /// + /// An . Do not enumerate more than once. + /// + public IAsyncEnumerable Messages { + get { + if (Interlocked.Exchange(ref _messagesEnumerated, 1) == 1) + throw new InvalidOperationException("Messages may only be enumerated once."); + + return GetMessages(); + + async IAsyncEnumerable GetMessages() { + try { + await foreach (var message in _channel.Reader.ReadAllAsync(_cts.Token)) { + if (message is PersistentSubscriptionMessage.SubscriptionConfirmation(var subscriptionId)) + SubscriptionId = subscriptionId; + + yield return message; + } + } + finally { + _cts.Cancel(); + } + } + } + } + + internal PersistentSubscriptionResult( + string streamName, string groupName, + Func> selectChannelInfo, + ReadReq request, KurrentClientSettings settings, UserCredentials? userCredentials, + CancellationToken cancellationToken + ) { + StreamName = streamName; + GroupName = groupName; + + _request = request; + + _callOptions = KurrentCallOptions.CreateStreaming( + settings, + userCredentials: userCredentials, + cancellationToken: cancellationToken + ); + + _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _ = PumpMessages(); + + return; + + async Task PumpMessages() { + try { + var channelInfo = await selectChannelInfo(_cts.Token).ConfigureAwait(false); + var client = new PersistentSubscriptionsClient(channelInfo.CallInvoker); + + _call = client.Read(_callOptions); + + await _call.RequestStream.WriteAsync(_request).ConfigureAwait(false); + + await foreach (var response in _call.ResponseStream.ReadAllAsync(_cts.Token).ConfigureAwait(false)) { + PersistentSubscriptionMessage subscriptionMessage = response.ContentCase switch { + SubscriptionConfirmation => new PersistentSubscriptionMessage.SubscriptionConfirmation( + response.SubscriptionConfirmation.SubscriptionId + ), + Event => new PersistentSubscriptionMessage.Event( + ConvertToResolvedEvent(response), + response.Event.CountCase switch { + ReadResp.Types.ReadEvent.CountOneofCase.RetryCount => response.Event.RetryCount, + _ => null + } + ), + _ => PersistentSubscriptionMessage.Unknown.Instance + }; + + if (subscriptionMessage is PersistentSubscriptionMessage.Event evnt) + KurrentClientDiagnostics.ActivitySource.TraceSubscriptionEvent( + SubscriptionId, + evnt.ResolvedEvent, + channelInfo, + settings, + userCredentials + ); + + await _channel.Writer.WriteAsync(subscriptionMessage, _cts.Token).ConfigureAwait(false); + } + + _channel.Writer.TryComplete(); + } catch (Exception ex) { +#if NET48 + switch (ex) { + // The gRPC client for .NET 48 uses WinHttpHandler under the hood for sending HTTP requests. + // In certain scenarios, this can lead to exceptions of type WinHttpException being thrown. + // One such scenario is when the server abruptly closes the connection, which results in a WinHttpException with the error code 12030. + // Additionally, there are cases where the server response does not include the 'grpc-status' header. + // The absence of this header leads to an RpcException with the status code 'Cancelled' and the message "No grpc-status found on response". + // The switch statement below handles these specific exceptions and translates them into the appropriate + // PersistentSubscriptionDroppedByServerException exception. + case RpcException { StatusCode: StatusCode.Unavailable } rex1 when rex1.Status.Detail.Contains("WinHttpException: Error 12030"): + case RpcException { StatusCode: StatusCode.Cancelled } rex2 + when rex2.Status.Detail.Contains("No grpc-status found on response"): + ex = new PersistentSubscriptionDroppedByServerException(StreamName, GroupName, ex); + break; + } +#endif + if (ex is PersistentSubscriptionNotFoundException) { + await _channel.Writer + .WriteAsync(PersistentSubscriptionMessage.NotFound.Instance, cancellationToken) + .ConfigureAwait(false); + + _channel.Writer.TryComplete(); + return; + } + + _channel.Writer.TryComplete(ex); + } + } + } + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The of the s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(params Uuid[] eventIds) => AckInternal(eventIds); + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The of the s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(IEnumerable eventIds) => Ack(eventIds.ToArray()); + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(params ResolvedEvent[] resolvedEvents) => + Ack(Array.ConvertAll(resolvedEvents, resolvedEvent => resolvedEvent.OriginalEvent.EventId)); + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(IEnumerable resolvedEvents) => + Ack(resolvedEvents.Select(resolvedEvent => resolvedEvent.OriginalEvent.EventId)); + + /// + /// Acknowledge that a message has failed processing (this will tell the server it has not been processed). + /// + /// The to take. + /// A reason given. + /// The of the s to nak. There should not be more than 2000 to nak at a time. + /// The number of eventIds exceeded the limit of 2000. + public Task Nack(PersistentSubscriptionNakEventAction action, string reason, params Uuid[] eventIds) => + NackInternal(eventIds, action, reason); + + /// + /// Acknowledge that a message has failed processing (this will tell the server it has not been processed). + /// + /// The to take. + /// A reason given. + /// The s to nak. There should not be more than 2000 to nak at a time. + /// The number of resolvedEvents exceeded the limit of 2000. + public Task Nack(PersistentSubscriptionNakEventAction action, string reason, params ResolvedEvent[] resolvedEvents) => + Nack(action, reason, Array.ConvertAll(resolvedEvents, re => re.OriginalEvent.EventId)); + + static ResolvedEvent ConvertToResolvedEvent(ReadResp response) => new( + ConvertToEventRecord(response.Event.Event)!, + ConvertToEventRecord(response.Event.Link), + response.Event.PositionCase switch { + ReadResp.Types.ReadEvent.PositionOneofCase.CommitPosition => response.Event.CommitPosition, + _ => null + } + ); + + Task AckInternal(params Uuid[] eventIds) { + if (eventIds.Length > MaxEventIdLength) { + throw new ArgumentException( + $"The number of eventIds exceeds the maximum length of {MaxEventIdLength}.", + nameof(eventIds) + ); + } + + return _call is null + ? throw new InvalidOperationException() + : _call.RequestStream.WriteAsync( + new ReadReq { + Ack = new ReadReq.Types.Ack { + Ids = { + Array.ConvertAll(eventIds, id => id.ToDto()) + } + } + } + ); + } + + Task NackInternal(Uuid[] eventIds, PersistentSubscriptionNakEventAction action, string reason) { + if (eventIds.Length > MaxEventIdLength) { + throw new ArgumentException( + $"The number of eventIds exceeds the maximum length of {MaxEventIdLength}.", + nameof(eventIds) + ); + } + + return _call is null + ? throw new InvalidOperationException() + : _call.RequestStream.WriteAsync( + new ReadReq { + Nack = new ReadReq.Types.Nack { + Ids = { + Array.ConvertAll(eventIds, id => id.ToDto()) + }, + Action = action switch { + PersistentSubscriptionNakEventAction.Park => ReadReq.Types.Nack.Types.Action.Park, + PersistentSubscriptionNakEventAction.Retry => ReadReq.Types.Nack.Types.Action.Retry, + PersistentSubscriptionNakEventAction.Skip => ReadReq.Types.Nack.Types.Action.Skip, + PersistentSubscriptionNakEventAction.Stop => ReadReq.Types.Nack.Types.Action.Stop, + _ => ReadReq.Types.Nack.Types.Action.Unknown + }, + Reason = reason + } + } + ); + } + + static EventRecord? ConvertToEventRecord(ReadResp.Types.ReadEvent.Types.RecordedEvent? e) => + e is null + ? null + : new EventRecord( + e.StreamIdentifier!, + Uuid.FromDto(e.Id), + new StreamPosition(e.StreamRevision), + new Position(e.CommitPosition, e.PreparePosition), + e.Metadata, + e.Data.ToByteArray(), + e.CustomMetadata.ToByteArray() + ); + + /// + public async ValueTask DisposeAsync() { + await CastAndDispose(_cts).ConfigureAwait(false); + await CastAndDispose(_call).ConfigureAwait(false); + + return; + + static async Task CastAndDispose(IDisposable? resource) { + switch (resource) { + case null: + return; + + case IAsyncDisposable resourceAsyncDisposable: + await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false); + break; + + default: + resource.Dispose(); + break; + } + } + } + + /// + public void Dispose() { + _cts.Dispose(); + _call?.Dispose(); + } + + /// + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { + await foreach (var message in Messages.WithCancellation(cancellationToken)) { + if (message is not PersistentSubscriptionMessage.Event(var resolvedEvent, _)) + continue; + + yield return resolvedEvent; + } + } + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.ReplayParked.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.ReplayParked.cs new file mode 100644 index 000000000..e13d4a4ad --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.ReplayParked.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.PersistentSubscriptions; +using Grpc.Core; +using NotSupportedException = System.NotSupportedException; + +#nullable enable +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + /// + /// Retry the parked messages of the persistent subscription + /// + public async Task ReplayParkedMessagesToAllAsync(string groupName, long? stopAt = null, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsReplayParked) { + var req = new ReplayParkedReq() { + Options = new ReplayParkedReq.Types.Options{ + GroupName = groupName, + All = new Empty() + }, + }; + + await ReplayParkedGrpcAsync(req, stopAt, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + + return; + } + + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsToAll) { + await ReplayParkedHttpAsync(SystemStreams.AllStream, groupName, stopAt, channelInfo, + deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + + return; + } + + throw new NotSupportedException("The server does not support persistent subscriptions to $all."); + } + + /// + /// Retry the parked messages of the persistent subscription + /// + public async Task ReplayParkedMessagesToStreamAsync(string streamName, string groupName, long? stopAt=null, + TimeSpan? deadline=null, UserCredentials? userCredentials=null, CancellationToken cancellationToken=default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsReplayParked) { + var req = new ReplayParkedReq() { + Options = new ReplayParkedReq.Types.Options { + GroupName = groupName, + StreamIdentifier = streamName + }, + }; + + await ReplayParkedGrpcAsync(req, stopAt, deadline, userCredentials, channelInfo.CallInvoker, cancellationToken) + .ConfigureAwait(false); + + return; + } + + await ReplayParkedHttpAsync(streamName, groupName, stopAt, channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + } + + private async Task ReplayParkedGrpcAsync(ReplayParkedReq req, long? numberOfEvents, TimeSpan? deadline, + UserCredentials? userCredentials, CallInvoker callInvoker, CancellationToken cancellationToken) { + + if (numberOfEvents.HasValue) { + req.Options.StopAt = numberOfEvents.Value; + } else { + req.Options.NoLimit = new Empty(); + } + + await new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient(callInvoker) + .ReplayParkedAsync(req, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) + .ConfigureAwait(false); + } + + private async Task ReplayParkedHttpAsync(string streamName, string groupName, long? numberOfEvents, + ChannelInfo channelInfo, TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { + + var path = $"/subscriptions/{UrlEncode(streamName)}/{UrlEncode(groupName)}/replayParked"; + var query = numberOfEvents.HasValue ? $"stopAt={numberOfEvents.Value}":""; + + await HttpPost(path, query, + onNotFound: () => throw new PersistentSubscriptionNotFoundException(streamName, groupName), + channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.RestartSubsystem.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.RestartSubsystem.cs new file mode 100644 index 000000000..a82587442 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.RestartSubsystem.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable +namespace EventStore.Client { + partial class KurrentPersistentSubscriptionsClient { + /// + /// Restarts the persistent subscriptions subsystem. + /// + public async Task RestartSubsystemAsync(TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsRestartSubsystem) { + await new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient(channelInfo.CallInvoker) + .RestartSubsystemAsync(new Empty(), KurrentCallOptions + .CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) + .ConfigureAwait(false); + return; + } + + await HttpPost( + path: "/subscriptions/restart", + query: "", + onNotFound: () => + throw new Exception("Unexpected exception while restarting the persistent subscription subsystem."), + channelInfo, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Update.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Update.cs new file mode 100644 index 000000000..19eb67d4b --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.Update.cs @@ -0,0 +1,161 @@ +using EventStore.Client.PersistentSubscriptions; + +namespace EventStore.Client { + public partial class KurrentPersistentSubscriptionsClient { + private static readonly IDictionary NamedConsumerStrategyToUpdateProto + = new Dictionary { + [SystemConsumerStrategies.DispatchToSingle] = UpdateReq.Types.ConsumerStrategy.DispatchToSingle, + [SystemConsumerStrategies.RoundRobin] = UpdateReq.Types.ConsumerStrategy.RoundRobin, + [SystemConsumerStrategies.Pinned] = UpdateReq.Types.ConsumerStrategy.Pinned, + }; + + private static UpdateReq.Types.StreamOptions StreamOptionsForUpdateProto(string streamName, + StreamPosition position) { + if (position == StreamPosition.Start) { + return new UpdateReq.Types.StreamOptions { + StreamIdentifier = streamName, + Start = new Empty() + }; + } + + if (position == StreamPosition.End) { + return new UpdateReq.Types.StreamOptions { + StreamIdentifier = streamName, + End = new Empty() + }; + } + + return new UpdateReq.Types.StreamOptions { + StreamIdentifier = streamName, + Revision = position.ToUInt64() + }; + } + + private static UpdateReq.Types.AllOptions AllOptionsForUpdateProto(Position position) { + if (position == Position.Start) { + return new UpdateReq.Types.AllOptions { + Start = new Empty() + }; + } + + if (position == Position.End) { + return new UpdateReq.Types.AllOptions { + End = new Empty() + }; + } + + return new UpdateReq.Types.AllOptions { + Position = new UpdateReq.Types.Position { + CommitPosition = position.CommitPosition, + PreparePosition = position.PreparePosition + } + }; + } + + + /// + /// Updates a persistent subscription. + /// + /// + [Obsolete("UpdateAsync is no longer supported. Use UpdateToStreamAsync instead.", false)] + public Task UpdateAsync(string streamName, string groupName, PersistentSubscriptionSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + UpdateToStreamAsync(streamName, groupName, settings, deadline, userCredentials, cancellationToken); + + /// + /// Updates a persistent subscription. + /// + /// + public async Task UpdateToStreamAsync(string streamName, string groupName, PersistentSubscriptionSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (streamName is null) { + throw new ArgumentNullException(nameof(streamName)); + } + + if (groupName is null) { + throw new ArgumentNullException(nameof(groupName)); + } + + if (settings is null) { + throw new ArgumentNullException(nameof(settings)); + } + + if (settings.ConsumerStrategyName is null) { + throw new ArgumentNullException(nameof(settings.ConsumerStrategyName)); + } + + if (streamName != SystemStreams.AllStream && settings.StartFrom is not null && + settings.StartFrom is not StreamPosition) { + throw new ArgumentException( + $"{nameof(settings.StartFrom)} must be of type '{nameof(StreamPosition)}' when subscribing to a stream"); + } + + if (streamName == SystemStreams.AllStream && settings.StartFrom is not null && + settings.StartFrom is not Position) { + throw new ArgumentException( + $"{nameof(settings.StartFrom)} must be of type '{nameof(Position)}' when subscribing to {SystemStreams.AllStream}"); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + + if (streamName == SystemStreams.AllStream && + !channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsToAll) { + throw new InvalidOperationException("The server does not support persistent subscriptions to $all."); + } + + using var call = new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient(channelInfo.CallInvoker) + .UpdateAsync(new UpdateReq { + Options = new UpdateReq.Types.Options { + GroupName = groupName, + Stream = streamName != SystemStreams.AllStream + ? StreamOptionsForUpdateProto(streamName, + (StreamPosition)(settings.StartFrom ?? StreamPosition.End)) + : null, + All = streamName == SystemStreams.AllStream + ? AllOptionsForUpdateProto((Position)(settings.StartFrom ?? Position.End)) + : null, +#pragma warning disable 612 + StreamIdentifier = + streamName != SystemStreams.AllStream + ? streamName + : string.Empty, /*for backwards compatibility*/ +#pragma warning restore 612 + Settings = new UpdateReq.Types.Settings { +#pragma warning disable 612 + Revision = streamName != SystemStreams.AllStream + ? ((StreamPosition)(settings.StartFrom ?? StreamPosition.End)).ToUInt64() + : default, /*for backwards compatibility*/ +#pragma warning restore 612 + CheckpointAfterMs = (int)settings.CheckPointAfter.TotalMilliseconds, + ExtraStatistics = settings.ExtraStatistics, + MessageTimeoutMs = (int)settings.MessageTimeout.TotalMilliseconds, + ResolveLinks = settings.ResolveLinkTos, + HistoryBufferSize = settings.HistoryBufferSize, + LiveBufferSize = settings.LiveBufferSize, + MaxCheckpointCount = settings.CheckPointUpperBound, + MaxRetryCount = settings.MaxRetryCount, + MaxSubscriberCount = settings.MaxSubscriberCount, + MinCheckpointCount = settings.CheckPointLowerBound, + NamedConsumerStrategy = + NamedConsumerStrategyToUpdateProto[settings.ConsumerStrategyName], + ReadBatchSize = settings.ReadBatchSize + } + } + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Updates a persistent subscription to $all. + /// + public async Task UpdateToAllAsync(string groupName, PersistentSubscriptionSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + await UpdateToStreamAsync(SystemStreams.AllStream, groupName, settings, deadline, userCredentials, + cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.cs new file mode 100644 index 000000000..070f32698 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClient.cs @@ -0,0 +1,46 @@ +using System.Text.Encodings.Web; +using System.Threading.Channels; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EventStore.Client { + /// + /// The client used to manage persistent subscriptions in the KurrentDB. + /// + public sealed partial class KurrentPersistentSubscriptionsClient : KurrentClientBase { + private static BoundedChannelOptions ReadBoundedChannelOptions = new (1) { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = true + }; + + private readonly ILogger _log; + + /// + /// Constructs a new . + /// + public KurrentPersistentSubscriptionsClient(KurrentClientSettings? settings) : base(settings, + new Dictionary> { + [Constants.Exceptions.PersistentSubscriptionDoesNotExist] = ex => new + PersistentSubscriptionNotFoundException( + ex.Trailers.First(x => x.Key == Constants.Exceptions.StreamName).Value, + ex.Trailers.FirstOrDefault(x => x.Key == Constants.Exceptions.GroupName)?.Value ?? "", ex), + [Constants.Exceptions.MaximumSubscribersReached] = ex => new + MaximumSubscribersReachedException( + ex.Trailers.First(x => x.Key == Constants.Exceptions.StreamName).Value, + ex.Trailers.First(x => x.Key == Constants.Exceptions.GroupName).Value, ex), + [Constants.Exceptions.PersistentSubscriptionDropped] = ex => new + PersistentSubscriptionDroppedByServerException( + ex.Trailers.First(x => x.Key == Constants.Exceptions.StreamName).Value, + ex.Trailers.First(x => x.Key == Constants.Exceptions.GroupName).Value, ex) + }) { + _log = Settings.LoggerFactory?.CreateLogger() + ?? new NullLogger(); + } + + private static string UrlEncode(string s) { + return UrlEncoder.Default.Encode(s); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClientCollectionExtensions.cs b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClientCollectionExtensions.cs new file mode 100644 index 000000000..9ed65e091 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/KurrentPersistentSubscriptionsClientCollectionExtensions.cs @@ -0,0 +1,61 @@ +// ReSharper disable CheckNamespace + +using System; +using System.Net.Http; +using EventStore.Client; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.DependencyInjection { + /// + /// A set of extension methods for which provide support for an . + /// + public static class KurrentPersistentSubscriptionsClientCollectionExtensions { + /// + /// Adds an to the . + /// + /// + public static IServiceCollection AddKurrentPersistentSubscriptionsClient(this IServiceCollection services, + Uri address, Func? createHttpMessageHandler = null) + => services.AddKurrentPersistentSubscriptionsClient(options => { + options.ConnectivitySettings.Address = address; + options.CreateHttpMessageHandler = createHttpMessageHandler; + }); + + /// + /// Adds an to the . + /// + /// + public static IServiceCollection AddKurrentPersistentSubscriptionsClient(this IServiceCollection services, + Action? configureSettings = null) => + services.AddKurrentPersistentSubscriptionsClient(new KurrentClientSettings(), + configureSettings); + + /// + /// Adds an to the . + /// + /// + public static IServiceCollection AddKurrentPersistentSubscriptionsClient(this IServiceCollection services, + string connectionString, Action? configureSettings = null) => + services.AddKurrentPersistentSubscriptionsClient(KurrentClientSettings.Create(connectionString), + configureSettings); + + private static IServiceCollection AddKurrentPersistentSubscriptionsClient(this IServiceCollection services, + KurrentClientSettings settings, Action? configureSettings) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + + configureSettings?.Invoke(settings); + services.TryAddSingleton(provider => { + settings.LoggerFactory ??= provider.GetService(); + settings.Interceptors ??= provider.GetServices(); + + return new KurrentPersistentSubscriptionsClient(settings); + }); + return services; + } + } +} +// ReSharper restore CheckNamespace diff --git a/src/Kurrent.Client/PersistentSubscriptions/MaximumSubscribersReachedException.cs b/src/Kurrent.Client/PersistentSubscriptions/MaximumSubscribersReachedException.cs new file mode 100644 index 000000000..879378f85 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/MaximumSubscribersReachedException.cs @@ -0,0 +1,30 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when the maximum number of subscribers on a persistent subscription is exceeded. + /// + public class MaximumSubscribersReachedException : Exception { + /// + /// The stream name. + /// + public readonly string StreamName; + /// + /// The group name. + /// + public readonly string GroupName; + + /// + /// Constructs a new . + /// + /// + public MaximumSubscribersReachedException(string streamName, string groupName, Exception? exception = null) + : base($"Maximum subscriptions reached for subscription group '{groupName}' on stream '{streamName}.'", + exception) { + if (streamName == null) throw new ArgumentNullException(nameof(streamName)); + if (groupName == null) throw new ArgumentNullException(nameof(groupName)); + StreamName = streamName; + GroupName = groupName; + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscription.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscription.cs new file mode 100644 index 000000000..637f54e30 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscription.cs @@ -0,0 +1,205 @@ +using EventStore.Client.PersistentSubscriptions; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace EventStore.Client { + /// + /// Represents a persistent subscription connection. + /// + public class PersistentSubscription : IDisposable { + private readonly KurrentPersistentSubscriptionsClient.PersistentSubscriptionResult _persistentSubscriptionResult; + private readonly IAsyncEnumerator _enumerator; + private readonly Func _eventAppeared; + private readonly Action _subscriptionDropped; + private readonly ILogger _log; + private readonly CancellationTokenSource _cts; + + private int _subscriptionDroppedInvoked; + + /// + /// The Subscription Id. + /// + public string SubscriptionId { get; } + + internal static async Task Confirm( + KurrentPersistentSubscriptionsClient.PersistentSubscriptionResult persistentSubscriptionResult, + Func eventAppeared, + Action subscriptionDropped, + ILogger log, UserCredentials? userCredentials, CancellationToken cancellationToken = default) { + var enumerator = persistentSubscriptionResult + .Messages + .GetAsyncEnumerator(cancellationToken); + + var result = await enumerator.MoveNextAsync(cancellationToken).ConfigureAwait(false); + + return (result, enumerator.Current) switch { + (true, PersistentSubscriptionMessage.SubscriptionConfirmation (var subscriptionId)) => + new PersistentSubscription(persistentSubscriptionResult, enumerator, subscriptionId, eventAppeared, + subscriptionDropped, log, cancellationToken), + (true, PersistentSubscriptionMessage.NotFound) => + throw new PersistentSubscriptionNotFoundException(persistentSubscriptionResult.StreamName, + persistentSubscriptionResult.GroupName), + _ => throw new InvalidOperationException("Subscription could not be confirmed.") + }; + } + + // PersistentSubscription takes responsibility for disposing the call and the disposable + private PersistentSubscription( + KurrentPersistentSubscriptionsClient.PersistentSubscriptionResult persistentSubscriptionResult, + IAsyncEnumerator enumerator, string subscriptionId, + Func eventAppeared, + Action subscriptionDropped, ILogger log, + CancellationToken cancellationToken) { + _persistentSubscriptionResult = persistentSubscriptionResult; + _enumerator = enumerator; + SubscriptionId = subscriptionId; + _eventAppeared = eventAppeared; + _subscriptionDropped = subscriptionDropped; + _log = log; + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Task.Run(Subscribe, _cts.Token); + } + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The of the s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(params Uuid[] eventIds) => AckInternal(eventIds); + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The of the s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(IEnumerable eventIds) => Ack(eventIds.ToArray()); + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(params ResolvedEvent[] resolvedEvents) => + Ack(Array.ConvertAll(resolvedEvents, resolvedEvent => resolvedEvent.OriginalEvent.EventId)); + + /// + /// Acknowledge that a message has completed processing (this will tell the server it has been processed). + /// + /// There is no need to ack a message if you have Auto Ack enabled. + /// The s to acknowledge. There should not be more than 2000 to ack at a time. + public Task Ack(IEnumerable resolvedEvents) => + Ack(resolvedEvents.Select(resolvedEvent => resolvedEvent.OriginalEvent.EventId)); + + + /// + /// Acknowledge that a message has failed processing (this will tell the server it has not been processed). + /// + /// The to take. + /// A reason given. + /// The of the s to nak. There should not be more than 2000 to nak at a time. + /// The number of eventIds exceeded the limit of 2000. + public Task Nack(PersistentSubscriptionNakEventAction action, string reason, params Uuid[] eventIds) => NackInternal(eventIds, action, reason); + + /// + /// Acknowledge that a message has failed processing (this will tell the server it has not been processed). + /// + /// The to take. + /// A reason given. + /// The s to nak. There should not be more than 2000 to nak at a time. + /// The number of resolvedEvents exceeded the limit of 2000. + public Task Nack(PersistentSubscriptionNakEventAction action, string reason, + params ResolvedEvent[] resolvedEvents) => + Nack(action, reason, + Array.ConvertAll(resolvedEvents, resolvedEvent => resolvedEvent.OriginalEvent.EventId)); + + /// + public void Dispose() => SubscriptionDropped(SubscriptionDroppedReason.Disposed); + + private async Task Subscribe() { + _log.LogDebug("Persistent Subscription {subscriptionId} confirmed.", SubscriptionId); + + try { + while (await _enumerator.MoveNextAsync(_cts.Token).ConfigureAwait(false)) { + if (_enumerator.Current is not PersistentSubscriptionMessage.Event(var resolvedEvent, var retryCount)) { + continue; + } + + if (_enumerator.Current is PersistentSubscriptionMessage.NotFound) { + if (_subscriptionDroppedInvoked != 0) { + return; + } + SubscriptionDropped(SubscriptionDroppedReason.ServerError, + new PersistentSubscriptionNotFoundException( + _persistentSubscriptionResult.StreamName, _persistentSubscriptionResult.GroupName)); + return; + } + + _log.LogTrace( + "Persistent Subscription {subscriptionId} received event {streamName}@{streamRevision} {position}", + SubscriptionId, resolvedEvent.OriginalEvent.EventStreamId, + resolvedEvent.OriginalEvent.EventNumber, resolvedEvent.OriginalEvent.Position); + + try { + await _eventAppeared( + this, + resolvedEvent, + retryCount, + _cts.Token).ConfigureAwait(false); + } catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) { + if (_subscriptionDroppedInvoked != 0) { + return; + } + + _log.LogWarning(ex, + "Persistent Subscription {subscriptionId} was dropped because cancellation was requested by another caller.", + SubscriptionId); + + SubscriptionDropped(SubscriptionDroppedReason.Disposed); + + return; + } catch (Exception ex) { + _log.LogError(ex, + "Persistent Subscription {subscriptionId} was dropped because the subscriber made an error.", + SubscriptionId); + SubscriptionDropped(SubscriptionDroppedReason.SubscriberError, ex); + + return; + } + } + } catch (Exception ex) { + if (_subscriptionDroppedInvoked == 0) { + _log.LogError(ex, + "Persistent Subscription {subscriptionId} was dropped because an error occurred on the server.", + SubscriptionId); + SubscriptionDropped(SubscriptionDroppedReason.ServerError, ex); + } + } finally { + if (_subscriptionDroppedInvoked == 0) { + _log.LogError( + "Persistent Subscription {subscriptionId} was unexpectedly terminated.", + SubscriptionId); + SubscriptionDropped(SubscriptionDroppedReason.ServerError); + } + } + } + + private void SubscriptionDropped(SubscriptionDroppedReason reason, Exception? ex = null) { + if (Interlocked.CompareExchange(ref _subscriptionDroppedInvoked, 1, 0) == 1) { + return; + } + + try { + _subscriptionDropped.Invoke(this, reason, ex); + } finally { + _persistentSubscriptionResult.Dispose(); + _cts.Dispose(); + } + } + + private Task AckInternal(params Uuid[] ids) => _persistentSubscriptionResult.Ack(ids); + + private Task NackInternal(Uuid[] ids, PersistentSubscriptionNakEventAction action, string reason) => + _persistentSubscriptionResult.Nack(action, reason, ids); + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionDroppedByServerException.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionDroppedByServerException.cs new file mode 100644 index 000000000..b25ea4f72 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionDroppedByServerException.cs @@ -0,0 +1,31 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when the KurrentDB drops a persistent subscription. + /// + public class PersistentSubscriptionDroppedByServerException : Exception { + /// + /// The stream name. + /// + public readonly string StreamName; + + /// + /// The group name. + /// + public readonly string GroupName; + + /// + /// Constructs a new . + /// + /// + public PersistentSubscriptionDroppedByServerException(string streamName, string groupName, + Exception? exception = null) + : base($"Subscription group '{groupName}' on stream '{streamName}' was dropped.", exception) { + if (streamName == null) throw new ArgumentNullException(nameof(streamName)); + if (groupName == null) throw new ArgumentNullException(nameof(groupName)); + StreamName = streamName; + GroupName = groupName; + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionExtraStatistic.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionExtraStatistic.cs new file mode 100644 index 000000000..5b41893c4 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionExtraStatistic.cs @@ -0,0 +1,23 @@ +namespace EventStore.Client; + +/// +/// Provides the definitions of the available extra statistics. +/// +public static class PersistentSubscriptionExtraStatistic { +#pragma warning disable CS1591 + public const string Highest = "Highest"; + public const string Mean = "Mean"; + public const string Median = "Median"; + public const string Fastest = "Fastest"; + public const string Quintile1 = "Quintile 1"; + public const string Quintile2 = "Quintile 2"; + public const string Quintile3 = "Quintile 3"; + public const string Quintile4 = "Quintile 4"; + public const string Quintile5 = "Quintile 5"; + public const string NinetyPercent = "90%"; + public const string NinetyFivePercent = "95%"; + public const string NinetyNinePercent = "99%"; + public const string NinetyNinePointFivePercent = "99.5%"; + public const string NinetyNinePointNinePercent = "99.9%"; +#pragma warning restore CS1591 +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs new file mode 100644 index 000000000..40af4cdf2 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using EventStore.Client.PersistentSubscriptions; +using Google.Protobuf.Collections; + +namespace EventStore.Client { + /// + /// Provides the details for a persistent subscription. + /// + /// The source of events for the subscription. + /// The group name given on creation. + /// The current status of the subscription. + /// Active connections to the subscription. + /// The settings used to create the persistant subscription. + /// Live statistics for the persistent subscription. + public record PersistentSubscriptionInfo(string EventSource, string GroupName, string Status, + IEnumerable Connections, + PersistentSubscriptionSettings? Settings, PersistentSubscriptionStats Stats) { + + internal static PersistentSubscriptionInfo From(SubscriptionInfo info) { + IPosition? startFrom = null; + IPosition? lastCheckpointedEventPosition = null; + IPosition? lastKnownEventPosition = null; + if (info.EventSource == SystemStreams.AllStream) { + if (Position.TryParse(info.StartFrom, out var position)) { + startFrom = position; + } + if (Position.TryParse(info.LastCheckpointedEventPosition, out position)) { + lastCheckpointedEventPosition = position; + } + if (Position.TryParse(info.LastKnownEventPosition, out position)) { + lastKnownEventPosition = position; + } + } else { + if (long.TryParse(info.StartFrom, out var streamPosition)) { + startFrom = StreamPosition.FromInt64(streamPosition); + } + if (ulong.TryParse(info.LastCheckpointedEventPosition, out var position)) { + lastCheckpointedEventPosition = new StreamPosition(position); + } + if (ulong.TryParse(info.LastKnownEventPosition, out position)) { + lastKnownEventPosition = new StreamPosition(position); + } + } + + return new PersistentSubscriptionInfo( + EventSource: info.EventSource, + GroupName: info.GroupName, + Status: info.Status, + Connections: From(info.Connections), + Settings: new PersistentSubscriptionSettings( + resolveLinkTos: info.ResolveLinkTos, + startFrom: startFrom, + extraStatistics: info.ExtraStatistics, + messageTimeout: TimeSpan.FromMilliseconds(info.MessageTimeoutMilliseconds), + maxRetryCount: info.MaxRetryCount, + liveBufferSize: info.LiveBufferSize, + readBatchSize: info.ReadBatchSize, + historyBufferSize: info.BufferSize, + checkPointAfter: TimeSpan.FromMilliseconds(info.CheckPointAfterMilliseconds), + checkPointLowerBound: info.MinCheckPointCount, + checkPointUpperBound: info.MaxCheckPointCount, + maxSubscriberCount: info.MaxSubscriberCount, + consumerStrategyName: info.NamedConsumerStrategy + ), + Stats: new PersistentSubscriptionStats( + AveragePerSecond: info.AveragePerSecond, + TotalItems: info.TotalItems, + CountSinceLastMeasurement: info.CountSinceLastMeasurement, + ReadBufferCount: info.ReadBufferCount, + LiveBufferCount: info.LiveBufferCount, + RetryBufferCount: info.RetryBufferCount, + TotalInFlightMessages: info.TotalInFlightMessages, + OutstandingMessagesCount: info.OutstandingMessagesCount, + ParkedMessageCount: info.ParkedMessageCount, + LastCheckpointedEventPosition: lastCheckpointedEventPosition, + LastKnownEventPosition: lastKnownEventPosition + ) + ); + } + + internal static PersistentSubscriptionInfo From(PersistentSubscriptionDto info) { + PersistentSubscriptionSettings? settings = null; + if (info.Config != null) { + settings = new PersistentSubscriptionSettings( + resolveLinkTos: info.Config.ResolveLinktos, + // we only need to support StreamPosition as $all was never implemented in http api. + startFrom: new StreamPosition(info.Config.StartFrom), + extraStatistics: info.Config.ExtraStatistics, + messageTimeout: TimeSpan.FromMilliseconds(info.Config.MessageTimeoutMilliseconds), + maxRetryCount: info.Config.MaxRetryCount, + liveBufferSize: info.Config.LiveBufferSize, + readBatchSize: info.Config.ReadBatchSize, + historyBufferSize: info.Config.BufferSize, + checkPointAfter: TimeSpan.FromMilliseconds(info.Config.CheckPointAfterMilliseconds), + checkPointLowerBound: info.Config.MinCheckPointCount, + checkPointUpperBound: info.Config.MaxCheckPointCount, + maxSubscriberCount: info.Config.MaxSubscriberCount, + consumerStrategyName: info.Config.NamedConsumerStrategy + ); + } + + return new PersistentSubscriptionInfo( + EventSource: info.EventStreamId, + GroupName: info.GroupName, + Status: info.Status, + Connections: PersistentSubscriptionConnectionInfo.CreateFrom(info.Connections), + Settings: settings, + Stats: new PersistentSubscriptionStats( + AveragePerSecond: (int)info.AverageItemsPerSecond, + TotalItems: info.TotalItemsProcessed, + CountSinceLastMeasurement: info.CountSinceLastMeasurement, + ReadBufferCount: info.ReadBufferCount, + LiveBufferCount: info.LiveBufferCount, + RetryBufferCount: info.RetryBufferCount, + TotalInFlightMessages: info.TotalInFlightMessages, + OutstandingMessagesCount: info.OutstandingMessagesCount, + ParkedMessageCount: info.ParkedMessageCount, + LastCheckpointedEventPosition: StreamPosition.FromInt64(info.LastProcessedEventNumber), + LastKnownEventPosition: StreamPosition.FromInt64(info.LastKnownEventNumber) + ) + ); + } + + private static IEnumerable From( + RepeatedField connections) { + foreach (var conn in connections) { + yield return new PersistentSubscriptionConnectionInfo( + From: conn.From, + Username: conn.Username, + AverageItemsPerSecond: conn.AverageItemsPerSecond, + TotalItems: conn.TotalItems, + CountSinceLastMeasurement: conn.CountSinceLastMeasurement, + AvailableSlots: conn.AvailableSlots, + InFlightMessages: conn.InFlightMessages, + ConnectionName: conn.ConnectionName, + ExtraStatistics: From(conn.ObservedMeasurements) + ); + } + } + + private static IDictionary From(IEnumerable measurements) => + measurements.ToDictionary(k => k.Key, v => v.Value); + } + + /// + /// Provides the statistics of a persistent subscription. + /// + /// Average number of events per second. + /// Total number of events processed by subscription. + /// Number of events seen since last measurement on this connection (used as the basis for ). + /// Number of events in the read buffer. + /// Number of events in the live buffer. + /// Number of events in the retry buffer. + /// Current in flight messages across all connections. + /// Current number of outstanding messages. + /// The current number of parked messages. + /// The of the last checkpoint. This will be null if there are no checkpoints. + /// The of the last known event. This will be undefined if no events have been received yet. + public record PersistentSubscriptionStats( + int AveragePerSecond, long TotalItems, long CountSinceLastMeasurement, int ReadBufferCount, + long LiveBufferCount, int RetryBufferCount, int TotalInFlightMessages, int OutstandingMessagesCount, + long ParkedMessageCount, IPosition? LastCheckpointedEventPosition, IPosition? LastKnownEventPosition); + + /// + /// Provides the details of a persistent subscription connection. + /// + /// Origin of this connection. + /// Connection username. + /// The name of the connection. + /// Average events per second on this connection. + /// Total items on this connection. + /// Number of items seen since last measurement on this connection (used as the basis for averageItemsPerSecond). + /// Number of available slots. + /// Number of in flight messages on this connection. + /// Timing measurements for the connection. Can be enabled with the ExtraStatistics setting. + public record PersistentSubscriptionConnectionInfo(string From, string Username, string ConnectionName, int AverageItemsPerSecond, + long TotalItems, long CountSinceLastMeasurement, int AvailableSlots, int InFlightMessages, + IDictionary ExtraStatistics) { + + internal static IEnumerable CreateFrom( + IEnumerable connections) { + + foreach (var connection in connections) { + yield return CreateFrom(connection); + } + } + + private static PersistentSubscriptionConnectionInfo CreateFrom(PersistentSubscriptionConnectionInfoDto connection) { + return new PersistentSubscriptionConnectionInfo( + From: connection.From, + Username: connection.Username, + ConnectionName: connection.ConnectionName, + AverageItemsPerSecond: (int)connection.AverageItemsPerSecond, + TotalItems: connection.TotalItems, + CountSinceLastMeasurement: connection.CountSinceLastMeasurement, + AvailableSlots: connection.AvailableSlots, + InFlightMessages: connection.InFlightMessages, + ExtraStatistics: CreateFrom(connection.ExtraStatistics) + ); + } + + private static IDictionary CreateFrom(IEnumerable extraStatistics) => + extraStatistics.ToDictionary(k => k.Key, v => v.Value); + } + + internal record PersistentSubscriptionDto(string EventStreamId, string GroupName, + string Status, float AverageItemsPerSecond, long TotalItemsProcessed, long CountSinceLastMeasurement, + long LastProcessedEventNumber, long LastKnownEventNumber, int ReadBufferCount, long LiveBufferCount, + int RetryBufferCount, int TotalInFlightMessages, int OutstandingMessagesCount, int ParkedMessageCount, + PersistentSubscriptionConfig? Config, IEnumerable Connections); + + internal record PersistentSubscriptionConfig(bool ResolveLinktos, ulong StartFrom, string StartPosition, + int MessageTimeoutMilliseconds, bool ExtraStatistics, int MaxRetryCount, int LiveBufferSize, int BufferSize, + int ReadBatchSize, int CheckPointAfterMilliseconds, int MinCheckPointCount, int MaxCheckPointCount, + int MaxSubscriberCount, string NamedConsumerStrategy); + + internal record PersistentSubscriptionConnectionInfoDto(string From, string Username, float AverageItemsPerSecond, + long TotalItems, long CountSinceLastMeasurement, int AvailableSlots, int InFlightMessages, string ConnectionName, + IEnumerable ExtraStatistics); + + internal record PersistentSubscriptionMeasurementInfoDto(string Key, long Value); +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionMessage.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionMessage.cs new file mode 100644 index 000000000..7cd0d848d --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionMessage.cs @@ -0,0 +1,33 @@ +namespace EventStore.Client { + /// + /// The base record of all stream messages. + /// + public abstract record PersistentSubscriptionMessage { + /// + /// A that represents a . + /// + /// The . + /// The number of times the has been retried. + public record Event(ResolvedEvent ResolvedEvent, int? RetryCount) : PersistentSubscriptionMessage; + + /// + /// A representing a stream that was not found. + /// + public record NotFound : PersistentSubscriptionMessage { + internal static readonly NotFound Instance = new(); + } + + /// + /// A indicating that the subscription is ready to send additional messages. + /// + /// The unique identifier of the subscription. + public record SubscriptionConfirmation(string SubscriptionId) : PersistentSubscriptionMessage; + + /// + /// A that could not be identified, usually indicating a lower client compatibility level than the server supports. + /// + public record Unknown : PersistentSubscriptionMessage { + internal static readonly Unknown Instance = new(); + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionNakEventAction.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionNakEventAction.cs new file mode 100644 index 000000000..ce24b6552 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionNakEventAction.cs @@ -0,0 +1,31 @@ +namespace EventStore.Client { + /// + /// Actions to be taken by server in the case of a client NAK + /// + public enum PersistentSubscriptionNakEventAction { + /// + /// Client unknown on action. Let server decide + /// + Unknown = 0, + + /// + /// Park message do not resend. Put on poison queue + /// + Park = 1, + + /// + /// Explicitly retry the message. + /// + Retry = 2, + + /// + /// Skip this message do not resend do not put in poison queue + /// + Skip = 3, + + /// + /// Stop the subscription. + /// + Stop = 4 + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionNotFoundException.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionNotFoundException.cs new file mode 100644 index 000000000..81e023ade --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionNotFoundException.cs @@ -0,0 +1,29 @@ +using System; + +namespace EventStore.Client { + /// + /// The exception that is thrown when a persistent subscription is not found. + /// + public class PersistentSubscriptionNotFoundException : Exception { + /// + /// The stream name. + /// + public readonly string StreamName; + /// + /// The group name. + /// + public readonly string GroupName; + + /// + /// Constructs a new . + /// + /// + public PersistentSubscriptionNotFoundException(string streamName, string groupName, Exception? exception = null) + : base($"Subscription group '{groupName}' on stream '{streamName}' does not exist.", exception) { + if (streamName == null) throw new ArgumentNullException(nameof(streamName)); + if (groupName == null) throw new ArgumentNullException(nameof(groupName)); + StreamName = streamName; + GroupName = groupName; + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionSettings.cs b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionSettings.cs new file mode 100644 index 000000000..a36bfff87 --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/PersistentSubscriptionSettings.cs @@ -0,0 +1,112 @@ +using System; + +namespace EventStore.Client { + /// + /// A class representing the settings of a persistent subscription. + /// + public sealed class PersistentSubscriptionSettings { + /// + /// Whether the should resolve linkTo events to their linked events. + /// + public readonly bool ResolveLinkTos; + + /// + /// Which event position in the stream or transaction file the subscription should start from. + /// + public readonly IPosition? StartFrom; + + /// + /// Whether to track latency statistics on this subscription. + /// + public readonly bool ExtraStatistics; + + /// + /// The amount of time after which to consider a message as timed out and retried. + /// + public readonly TimeSpan MessageTimeout; + + /// + /// The maximum number of retries (due to timeout) before a message is considered to be parked. + /// + public readonly int MaxRetryCount; + + /// + /// The size of the buffer (in-memory) listening to live messages as they happen before paging occurs. + /// + public readonly int LiveBufferSize; + + /// + /// The number of events read at a time when paging through history. + /// + public readonly int ReadBatchSize; + + /// + /// The number of events to cache when paging through history. + /// + public readonly int HistoryBufferSize; + + /// + /// The amount of time to try to checkpoint after. + /// + public readonly TimeSpan CheckPointAfter; + + /// + /// The minimum number of messages to process before a checkpoint may be written. + /// + public readonly int CheckPointLowerBound; + + /// + /// The maximum number of messages not checkpointed before forcing a checkpoint. + /// + public readonly int CheckPointUpperBound; + + /// + /// The maximum number of subscribers allowed. + /// + public readonly int MaxSubscriberCount; + + /// + /// The strategy to use for distributing events to client consumers. See for system supported strategies. + /// + public readonly string ConsumerStrategyName; + + /// + /// Constructs a new . + /// + /// + public PersistentSubscriptionSettings(bool resolveLinkTos = false, IPosition? startFrom = null, + bool extraStatistics = false, TimeSpan? messageTimeout = null, int maxRetryCount = 10, + int liveBufferSize = 500, int readBatchSize = 20, int historyBufferSize = 500, + TimeSpan? checkPointAfter = null, int checkPointLowerBound = 10, int checkPointUpperBound = 1000, + int maxSubscriberCount = 0, string consumerStrategyName = SystemConsumerStrategies.RoundRobin) { + messageTimeout ??= TimeSpan.FromSeconds(30); + checkPointAfter ??= TimeSpan.FromSeconds(2); + + if (messageTimeout.Value < TimeSpan.Zero || messageTimeout.Value.TotalMilliseconds > int.MaxValue) { + throw new ArgumentOutOfRangeException( + nameof(messageTimeout), + $"{nameof(messageTimeout)} must be greater than {TimeSpan.Zero} and less than or equal to {TimeSpan.FromMilliseconds(int.MaxValue)}"); + } + + if (checkPointAfter.Value < TimeSpan.Zero || checkPointAfter.Value.TotalMilliseconds > int.MaxValue) { + throw new ArgumentOutOfRangeException( + nameof(checkPointAfter), + $"{nameof(checkPointAfter)} must be greater than {TimeSpan.Zero} and less than or equal to {TimeSpan.FromMilliseconds(int.MaxValue)}"); + } + + ResolveLinkTos = resolveLinkTos; + StartFrom = startFrom; + ExtraStatistics = extraStatistics; + MessageTimeout = messageTimeout.Value; + MaxRetryCount = maxRetryCount; + LiveBufferSize = liveBufferSize; + ReadBatchSize = readBatchSize; + HistoryBufferSize = historyBufferSize; + CheckPointAfter = checkPointAfter.Value; + CheckPointLowerBound = checkPointLowerBound; + CheckPointUpperBound = checkPointUpperBound; + MaxSubscriberCount = maxSubscriberCount; + ConsumerStrategyName = consumerStrategyName; + } + } +} diff --git a/src/Kurrent.Client/PersistentSubscriptions/SystemConsumerStrategies.cs b/src/Kurrent.Client/PersistentSubscriptions/SystemConsumerStrategies.cs new file mode 100644 index 000000000..6183461ca --- /dev/null +++ b/src/Kurrent.Client/PersistentSubscriptions/SystemConsumerStrategies.cs @@ -0,0 +1,22 @@ +namespace EventStore.Client { + /// + /// System supported consumer strategies for use with persistent subscriptions. + /// + public static class SystemConsumerStrategies { + /// + /// Distributes events to a single client until it is full. Then round robin to the next client. + /// + public const string DispatchToSingle = nameof(DispatchToSingle); + + /// + /// Distribute events to each client in a round robin fashion. + /// + public const string RoundRobin = nameof(RoundRobin); + + /// + /// Distribute events of the same streamId to the same client until it disconnects on a best efforts basis. + /// Designed to be used with indexes such as the category projection. + /// + public const string Pinned = nameof(Pinned); + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Control.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Control.cs new file mode 100644 index 000000000..ff6ba9619 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Control.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Projections; + +namespace EventStore.Client { + public partial class KurrentProjectionManagementClient { + /// + /// Enables a projection. + /// + /// + /// + /// + /// + /// + public async Task EnableAsync(string name, TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).EnableAsync(new EnableReq { + Options = new EnableReq.Types.Options { + Name = name + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Resets a projection. This will re-emit events. Streams that are written to from the projection will also be soft deleted. + /// + /// + /// + /// + /// + /// + public async Task ResetAsync(string name, TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).ResetAsync(new ResetReq { + Options = new ResetReq.Types.Options { + Name = name, + WriteCheckpoint = true + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Aborts a projection. Does not save the projection's checkpoint. + /// + /// + /// + /// + /// + /// + public Task AbortAsync(string name, TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + DisableInternalAsync(name, false, deadline, userCredentials, cancellationToken); + + /// + /// Disables a projection. Saves the projection's checkpoint. + /// + /// + /// + /// + /// + /// + public Task DisableAsync(string name, TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + DisableInternalAsync(name, true, deadline, userCredentials, cancellationToken); + + /// + /// Restarts the projection subsystem. + /// + /// + /// + /// + /// + public async Task RestartSubsystemAsync(TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).RestartSubsystemAsync(new Empty(), + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + private async Task DisableInternalAsync(string name, bool writeCheckpoint, TimeSpan? deadline, + UserCredentials? userCredentials, CancellationToken cancellationToken) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).DisableAsync(new DisableReq { + Options = new DisableReq.Types.Options { + Name = name, + WriteCheckpoint = writeCheckpoint + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Create.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Create.cs new file mode 100644 index 000000000..cae9b400e --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Create.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Projections; + +namespace EventStore.Client { + public partial class KurrentProjectionManagementClient { + /// + /// Creates a one-time projection. + /// + /// + /// + /// + /// + /// + public async Task CreateOneTimeAsync(string query, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).CreateAsync(new CreateReq { + Options = new CreateReq.Types.Options { + OneTime = new Empty(), + Query = query + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Creates a continuous projection. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task CreateContinuousAsync(string name, string query, bool trackEmittedStreams = false, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).CreateAsync(new CreateReq { + Options = new CreateReq.Types.Options { + Continuous = new CreateReq.Types.Options.Types.Continuous { + Name = name, + TrackEmittedStreams = trackEmittedStreams + }, + Query = query + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Creates a transient projection. + /// + /// + /// + /// + /// + /// + /// + public async Task CreateTransientAsync(string name, string query, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).CreateAsync(new CreateReq { + Options = new CreateReq.Types.Options { + Transient = new CreateReq.Types.Options.Types.Transient { + Name = name + }, + Query = query + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.State.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.State.cs new file mode 100644 index 000000000..ca24d6ef9 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.State.cs @@ -0,0 +1,205 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Projections; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; + +namespace EventStore.Client { + public partial class KurrentProjectionManagementClient { + static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new JsonSerializerOptions(); + + /// + /// Gets the result of a projection as an untyped document. + /// + /// + /// + /// + /// + /// + /// + public async Task GetResultAsync(string name, string? partition = null, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var value = await GetResultInternalAsync(name, partition, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + +#if NET + await using var stream = new MemoryStream(); +#else + using var stream = new MemoryStream(); +#endif + await using var writer = new Utf8JsonWriter(stream); + var serializer = new ValueSerializer(); + serializer.Write(writer, value, DefaultJsonSerializerOptions); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Position = 0; + + return JsonDocument.Parse(stream); + } + + /// + /// Gets the result of a projection. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public async Task GetResultAsync(string name, string? partition = null, + JsonSerializerOptions? serializerOptions = null, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var value = await GetResultInternalAsync(name, partition, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); +#if NET + await using var stream = new MemoryStream(); +#else + using var stream = new MemoryStream(); +#endif + await using var writer = new Utf8JsonWriter(stream); + var serializer = new ValueSerializer(); + serializer.Write(writer, value, DefaultJsonSerializerOptions); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Position = 0; + + return JsonSerializer.Deserialize(stream.ToArray(), serializerOptions)!; + } + + private async ValueTask GetResultInternalAsync(string name, string? partition, + TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).ResultAsync(new ResultReq { + Options = new ResultReq.Types.Options { + Name = name, + Partition = partition ?? string.Empty + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + var response = await call.ResponseAsync.ConfigureAwait(false); + return response.Result; + } + + /// + /// Gets the state of a projection as an untyped document. + /// + /// + /// + /// + /// + /// + /// + public async Task GetStateAsync(string name, string? partition = null, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var value = await GetStateInternalAsync(name, partition, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + +#if NET + await using var stream = new MemoryStream(); +#else + using var stream = new MemoryStream(); +#endif + await using var writer = new Utf8JsonWriter(stream); + var serializer = new ValueSerializer(); + serializer.Write(writer, value, DefaultJsonSerializerOptions); + stream.Position = 0; + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + + return JsonDocument.Parse(stream); + } + + /// + /// Gets the state of a projection. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public async Task GetStateAsync(string name, string? partition = null, + JsonSerializerOptions? serializerOptions = null, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + var value = await GetStateInternalAsync(name, partition, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + +#if NET + await using var stream = new MemoryStream(); +#else + using var stream = new MemoryStream(); +#endif + await using var writer = new Utf8JsonWriter(stream); + var serializer = new ValueSerializer(); + serializer.Write(writer, value, DefaultJsonSerializerOptions); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Position = 0; + + return JsonSerializer.Deserialize(stream.ToArray(), serializerOptions)!; + } + + private async ValueTask GetStateInternalAsync(string name, string? partition, TimeSpan? deadline, + UserCredentials? userCredentials, CancellationToken cancellationToken) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).StateAsync(new StateReq { + Options = new StateReq.Types.Options { + Name = name, + Partition = partition ?? string.Empty + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + var response = await call.ResponseAsync.ConfigureAwait(false); + return response.State; + } + + private class ValueSerializer : System.Text.Json.Serialization.JsonConverter { + public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options) { + switch (value.KindCase) { + case Value.KindOneofCase.None: + break; + case Value.KindOneofCase.BoolValue: + writer.WriteBooleanValue(value.BoolValue); + break; + case Value.KindOneofCase.NullValue: + writer.WriteNullValue(); + break; + case Value.KindOneofCase.NumberValue: + writer.WriteNumberValue(value.NumberValue); + break; + case Value.KindOneofCase.StringValue: + writer.WriteStringValue(value.StringValue); + break; + case Value.KindOneofCase.ListValue: + writer.WriteStartArray(); + foreach (var item in value.ListValue.Values) { + Write(writer, item, options); + } + + writer.WriteEndArray(); + break; + case Value.KindOneofCase.StructValue: + writer.WriteStartObject(); + foreach (var map in value.StructValue.Fields) { + writer.WritePropertyName(map.Key); + Write(writer, map.Value, options); + } + + writer.WriteEndObject(); + break; + } + } + } + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Statistics.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Statistics.cs new file mode 100644 index 000000000..425831165 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Statistics.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Projections; +using Grpc.Core; + +namespace EventStore.Client { + public partial class KurrentProjectionManagementClient { + /// + /// List the of all one-time projections. + /// + /// + /// + /// + /// + public IAsyncEnumerable ListOneTimeAsync(TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) => + ListInternalAsync(new StatisticsReq.Types.Options { + OneTime = new Empty() + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken), + cancellationToken); + + /// + /// List the of all continuous projections. + /// + /// + /// + /// + /// + public IAsyncEnumerable ListContinuousAsync(TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + ListInternalAsync(new StatisticsReq.Types.Options { + Continuous = new Empty() + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken), + cancellationToken); + + /// + /// Gets the status of a projection. + /// + /// + /// + /// + /// + /// + public Task GetStatusAsync(string name, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => ListInternalAsync(new StatisticsReq.Types.Options { + Name = name + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken), + cancellationToken) + .FirstOrDefaultAsync(cancellationToken).AsTask(); + + /// + /// List the of all projections. + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAllAsync(TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) => + ListInternalAsync(new StatisticsReq.Types.Options { + All = new Empty() + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken), + cancellationToken); + + private async IAsyncEnumerable ListInternalAsync(StatisticsReq.Types.Options options, + CallOptions callOptions, + [EnumeratorCancellation] CancellationToken cancellationToken) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).Statistics(new StatisticsReq { + Options = options + }, callOptions); + + await foreach (var projectionDetails in call.ResponseStream + .ReadAllAsync(cancellationToken) + .Select(ConvertToProjectionDetails) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) { + yield return projectionDetails; + } + } + + private static ProjectionDetails ConvertToProjectionDetails(StatisticsResp response) { + var details = response.Details; + + return new ProjectionDetails(details.CoreProcessingTime, details.Version, details.Epoch, + details.EffectiveName, details.WritesInProgress, details.ReadsInProgress, details.PartitionsCached, + details.Status, details.StateReason, details.Name, details.Mode, details.Position, details.Progress, + details.LastCheckpoint, details.EventsProcessedAfterRestart, details.CheckpointStatus, + details.BufferedEvents, details.WritePendingEventsBeforeCheckpoint, + details.WritePendingEventsAfterCheckpoint); + } + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Update.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Update.cs new file mode 100644 index 000000000..368ff3be4 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.Update.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Projections; + +namespace EventStore.Client { + public partial class KurrentProjectionManagementClient { + /// + /// Updates a projection. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task UpdateAsync(string name, string query, bool? emitEnabled = null, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var options = new UpdateReq.Types.Options { + Name = name, + Query = query + }; + if (emitEnabled.HasValue) { + options.EmitEnabled = emitEnabled.Value; + } else { + options.NoEmitOptions = new Empty(); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Projections.Projections.ProjectionsClient( + channelInfo.CallInvoker).UpdateAsync(new UpdateReq { + Options = options + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + await call.ResponseAsync.ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.cs new file mode 100644 index 000000000..12e1abf18 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClient.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace EventStore.Client { + /// + ///The client used to manage projections on the KurrentDB. + /// + public sealed partial class KurrentProjectionManagementClient : KurrentClientBase { + private readonly ILogger _log; + + /// + /// Constructs a new . This method is not intended to be called directly from your code. + /// + /// + public KurrentProjectionManagementClient(IOptions options) : this(options.Value) { + } + + /// + /// Constructs a new . + /// + /// + public KurrentProjectionManagementClient(KurrentClientSettings? settings) : base(settings, + new Dictionary>()) { + _log = settings?.LoggerFactory?.CreateLogger() ?? + new NullLogger(); + } + } +} diff --git a/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClientCollectionExtensions.cs b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClientCollectionExtensions.cs new file mode 100644 index 000000000..5fe3932b7 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/KurrentProjectionManagementClientCollectionExtensions.cs @@ -0,0 +1,74 @@ +// ReSharper disable CheckNamespace + +using System; +using System.Net.Http; +using EventStore.Client; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.DependencyInjection { + /// + /// A set of extension methods for which provide support for an . + /// + public static class KurrentProjectionManagementClientCollectionExtensions { + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentProjectionManagementClient(this IServiceCollection services, + Uri address, + Func? createHttpMessageHandler = null) + => services.AddKurrentProjectionManagementClient(options => { + options.ConnectivitySettings.Address = address; + options.CreateHttpMessageHandler = createHttpMessageHandler; + }); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentProjectionManagementClient(this IServiceCollection services, + Action? configureSettings = null) => + services.AddKurrentProjectionManagementClient(new KurrentClientSettings(), configureSettings); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentProjectionManagementClient(this IServiceCollection services, + string connectionString, Action? configureSettings = null) => + services.AddKurrentProjectionManagementClient(KurrentClientSettings.Create(connectionString), + configureSettings); + + private static IServiceCollection AddKurrentProjectionManagementClient(this IServiceCollection services, + KurrentClientSettings settings, Action? configureSettings) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + + configureSettings?.Invoke(settings); + + services.TryAddSingleton(provider => { + settings.LoggerFactory ??= provider.GetService(); + settings.Interceptors ??= provider.GetServices(); + + return new KurrentProjectionManagementClient(settings); + }); + + return services; + } + } +} +// ReSharper restore CheckNamespace diff --git a/src/Kurrent.Client/ProjectionManagement/ProjectionDetails.cs b/src/Kurrent.Client/ProjectionManagement/ProjectionDetails.cs new file mode 100644 index 000000000..c604362b1 --- /dev/null +++ b/src/Kurrent.Client/ProjectionManagement/ProjectionDetails.cs @@ -0,0 +1,164 @@ +namespace EventStore.Client { + /// + /// Provides the details for a projection. + /// + public sealed class ProjectionDetails { + /// + /// The CoreProcessingTime + /// + public readonly long CoreProcessingTime; + + /// + /// The projection version + /// + public readonly long Version; + + /// + /// The Epoch + /// + public readonly long Epoch; + + /// + /// The projection EffectiveName + /// + public readonly string EffectiveName; + + /// + /// The projection WritesInProgress + /// + public readonly int WritesInProgress; + + /// + /// The projection ReadsInProgress + /// + public readonly int ReadsInProgress; + + /// + /// The projection PartitionsCached + /// + public readonly int PartitionsCached; + + /// + /// The projection Status + /// + public readonly string Status; + + /// + /// The projection StateReason + /// + public readonly string StateReason; + + /// + /// The projection Name + /// + public readonly string Name; + + /// + /// The projection Mode + /// + public readonly string Mode; + + /// + /// The projection Position + /// + public readonly string Position; + + /// + /// The projection Progress + /// + public readonly float Progress; + + /// + /// LastCheckpoint + /// + public readonly string LastCheckpoint; + + /// + /// The projection EventsProcessedAfterRestart + /// + public readonly long EventsProcessedAfterRestart; + + /// + /// The projection CheckpointStatus + /// + public readonly string CheckpointStatus; + + /// + /// The projection BufferedEvents + /// + public readonly long BufferedEvents; + + /// + /// The projection WritePendingEventsBeforeCheckpoint + /// + public readonly int WritePendingEventsBeforeCheckpoint; + + /// + /// The projection WritePendingEventsAfterCheckpoint + /// + public readonly int WritePendingEventsAfterCheckpoint; + + /// + /// create a new class. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ProjectionDetails( + long coreProcessingTime, + long version, + long epoch, + string effectiveName, + int writesInProgress, + int readsInProgress, + int partitionsCached, + string status, + string stateReason, + string name, + string mode, + string position, + float progress, + string lastCheckpoint, + long eventsProcessedAfterRestart, + string checkpointStatus, + long bufferedEvents, + int writePendingEventsBeforeCheckpoint, + int writePendingEventsAfterCheckpoint) { + CoreProcessingTime = coreProcessingTime; + Version = version; + Epoch = epoch; + EffectiveName = effectiveName; + WritesInProgress = writesInProgress; + ReadsInProgress = readsInProgress; + PartitionsCached = partitionsCached; + Status = status; + StateReason = stateReason; + Name = name; + Mode = mode; + Position = position; + Progress = progress; + LastCheckpoint = lastCheckpoint; + EventsProcessedAfterRestart = eventsProcessedAfterRestart; + CheckpointStatus = checkpointStatus; + BufferedEvents = bufferedEvents; + WritePendingEventsBeforeCheckpoint = writePendingEventsBeforeCheckpoint; + WritePendingEventsAfterCheckpoint = writePendingEventsAfterCheckpoint; + } + } +} diff --git a/src/Kurrent.Client/Streams/ConditionalWriteResult.cs b/src/Kurrent.Client/Streams/ConditionalWriteResult.cs new file mode 100644 index 000000000..f51ef577b --- /dev/null +++ b/src/Kurrent.Client/Streams/ConditionalWriteResult.cs @@ -0,0 +1,87 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure that represents the result of a conditional write. + /// + public readonly struct ConditionalWriteResult : IEquatable { + /// + /// Indicates that the stream the operation is targeting was deleted. + /// + public static readonly ConditionalWriteResult StreamDeleted = + new ConditionalWriteResult(StreamRevision.None, Position.End, ConditionalWriteStatus.StreamDeleted); + + /// + /// The correct expected version to use when writing to the stream next. + /// + public long NextExpectedVersion { get; } + + /// + /// The of the write in the transaction file. + /// + public Position LogPosition { get; } + + /// + /// The . + /// + public ConditionalWriteStatus Status { get; } + + /// + /// The correct to use when writing to the stream next. + /// + public StreamRevision NextExpectedStreamRevision { get; } + + private ConditionalWriteResult(StreamRevision nextExpectedStreamRevision, Position logPosition, + ConditionalWriteStatus status = ConditionalWriteStatus.Succeeded) { + NextExpectedStreamRevision = nextExpectedStreamRevision; + NextExpectedVersion = nextExpectedStreamRevision.ToInt64(); + LogPosition = logPosition; + Status = status; + } + + internal static ConditionalWriteResult FromWriteResult(IWriteResult writeResult) + => writeResult switch { + WrongExpectedVersionResult wrongExpectedVersion => + new ConditionalWriteResult(wrongExpectedVersion.NextExpectedStreamRevision, Position.End, + ConditionalWriteStatus.VersionMismatch), + _ => new ConditionalWriteResult(writeResult.NextExpectedStreamRevision, writeResult.LogPosition) + }; + + internal static ConditionalWriteResult FromWrongExpectedVersion(WrongExpectedVersionException ex) + => new ConditionalWriteResult(ex.ExpectedStreamRevision, Position.End, + ConditionalWriteStatus.VersionMismatch); + + /// + public bool Equals(ConditionalWriteResult other) => + NextExpectedStreamRevision == other.NextExpectedStreamRevision && + LogPosition.Equals(other.LogPosition) && + Status == other.Status; + + /// + public override bool Equals(object? obj) => obj is ConditionalWriteResult other && Equals(other); + + /// + public override int GetHashCode() => + HashCode.Hash.Combine(NextExpectedVersion).Combine(LogPosition).Combine(Status); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(ConditionalWriteResult left, ConditionalWriteResult right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(ConditionalWriteResult left, ConditionalWriteResult right) => + !left.Equals(right); + + /// + public override string ToString() => $"{Status}:{NextExpectedVersion}:{LogPosition}"; + } +} diff --git a/src/Kurrent.Client/Streams/ConditionalWriteStatus.cs b/src/Kurrent.Client/Streams/ConditionalWriteStatus.cs new file mode 100644 index 000000000..93bf82fe7 --- /dev/null +++ b/src/Kurrent.Client/Streams/ConditionalWriteStatus.cs @@ -0,0 +1,21 @@ +namespace EventStore.Client { + /// + /// The reason why a conditional write fails + /// + public enum ConditionalWriteStatus { + /// + /// The write operation succeeded + /// + Succeeded = 0, + + /// + /// The expected version does not match actual stream version + /// + VersionMismatch = 1, + + /// + /// The stream has been deleted + /// + StreamDeleted = 2 + } +} diff --git a/src/Kurrent.Client/Streams/DeadLine.cs b/src/Kurrent.Client/Streams/DeadLine.cs new file mode 100644 index 000000000..b4ba29b49 --- /dev/null +++ b/src/Kurrent.Client/Streams/DeadLine.cs @@ -0,0 +1,12 @@ +using System; + +namespace EventStore.Client { +#pragma warning disable CS1591 + public static class DeadLine { +#pragma warning restore CS1591 + /// + /// Represents no deadline (i.e., wait infinitely) + /// + public static TimeSpan? None = null; + } +} diff --git a/src/Kurrent.Client/Streams/DeleteResult.cs b/src/Kurrent.Client/Streams/DeleteResult.cs new file mode 100644 index 000000000..ea5195b1d --- /dev/null +++ b/src/Kurrent.Client/Streams/DeleteResult.cs @@ -0,0 +1,46 @@ +using System; + +namespace EventStore.Client { + /// + /// A structure that represents the result of a delete operation. + /// + public readonly struct DeleteResult : IEquatable { + /// + public bool Equals(DeleteResult other) => LogPosition.Equals(other.LogPosition); + + /// + public override bool Equals(object? obj) => obj is DeleteResult other && Equals(other); + + /// + public override int GetHashCode() => LogPosition.GetHashCode(); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(DeleteResult left, DeleteResult right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(DeleteResult left, DeleteResult right) => !left.Equals(right); + + /// + /// The of the delete in the transaction file. + /// + public readonly Position LogPosition; + + /// + /// Constructs a new . + /// + /// + public DeleteResult(Position logPosition) { + LogPosition = logPosition; + } + } +} diff --git a/src/Kurrent.Client/Streams/Direction.cs b/src/Kurrent.Client/Streams/Direction.cs new file mode 100644 index 000000000..40e9489da --- /dev/null +++ b/src/Kurrent.Client/Streams/Direction.cs @@ -0,0 +1,16 @@ +namespace EventStore.Client { + /// + /// An enumeration that indicates the direction of the read operation. + /// + public enum Direction { + /// + /// Read backwards. + /// + Backwards, + + /// + /// Read forwards. + /// + Forwards + } +} diff --git a/src/Kurrent.Client/Streams/IWriteResult.cs b/src/Kurrent.Client/Streams/IWriteResult.cs new file mode 100644 index 000000000..8fe9d530c --- /dev/null +++ b/src/Kurrent.Client/Streams/IWriteResult.cs @@ -0,0 +1,23 @@ +using System; + +namespace EventStore.Client { + /// + /// An interface representing the result of a write operation. + /// + public interface IWriteResult { + /// + /// The version the stream is currently at. + /// + [Obsolete("Please use NextExpectedStreamRevision instead. This property will be removed in a future version.", + true)] + long NextExpectedVersion { get; } + /// + /// The of the in the transaction file. + /// + Position LogPosition { get; } + /// + /// The the stream is currently at. + /// + StreamRevision NextExpectedStreamRevision { get; } + } +} diff --git a/src/Kurrent.Client/Streams/InvalidTransactionException.cs b/src/Kurrent.Client/Streams/InvalidTransactionException.cs new file mode 100644 index 000000000..f71505671 --- /dev/null +++ b/src/Kurrent.Client/Streams/InvalidTransactionException.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.Serialization; + +namespace EventStore.Client; + +/// +/// Exception thrown if there is an attempt to operate inside a +/// transaction which does not exist. +/// +public class InvalidTransactionException : Exception { + /// + /// Constructs a new . + /// + public InvalidTransactionException() { } + + /// + /// Constructs a new . + /// + public InvalidTransactionException(string message) : base(message) { } + + /// + /// Constructs a new . + /// + public InvalidTransactionException(string message, Exception innerException) : base(message, innerException) { } + + /// + /// Constructs a new . + /// + [Obsolete("Obsolete")] + protected InvalidTransactionException(SerializationInfo info, StreamingContext context) : base(info, context) { } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.Append.cs b/src/Kurrent.Client/Streams/KurrentClient.Append.cs new file mode 100644 index 000000000..39d6f3066 --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.Append.cs @@ -0,0 +1,430 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading.Channels; +using Google.Protobuf; +using EventStore.Client.Streams; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using EventStore.Client.Diagnostics; +using Kurrent.Diagnostics; +using Kurrent.Diagnostics.Telemetry; +using Kurrent.Diagnostics.Tracing; +using static EventStore.Client.Streams.AppendResp.Types.WrongExpectedVersion; +using static EventStore.Client.Streams.Streams; + +namespace EventStore.Client { + public partial class KurrentClient { + /// + /// Appends events asynchronously to a stream. + /// + /// The name of the stream to append events to. + /// The expected of the stream to append to. + /// An to append to the stream. + /// An to configure the operation's options. + /// + /// The for the operation. + /// The optional . + /// + public async Task AppendToStreamAsync( + string streamName, + StreamRevision expectedRevision, + IEnumerable eventData, + Action? configureOperationOptions = null, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) { + var options = Settings.OperationOptions.Clone(); + configureOperationOptions?.Invoke(options); + + _log.LogDebug("Append to stream - {streamName}@{expectedRevision}.", streamName, expectedRevision); + + var task = userCredentials is null && await BatchAppender.IsUsable().ConfigureAwait(false) + ? BatchAppender.Append(streamName, expectedRevision, eventData, deadline, cancellationToken) + : AppendToStreamInternal( + await GetChannelInfo(cancellationToken).ConfigureAwait(false), + new AppendReq { + Options = new() { + StreamIdentifier = streamName, + Revision = expectedRevision + } + }, + eventData, + options, + deadline, + userCredentials, + cancellationToken + ); + + return (await task.ConfigureAwait(false)).OptionallyThrowWrongExpectedVersionException(options); + } + + /// + /// Appends events asynchronously to a stream. + /// + /// The name of the stream to append events to. + /// The expected of the stream to append to. + /// An to append to the stream. + /// An to configure the operation's options. + /// + /// The for the operation. + /// The optional . + /// + public async Task AppendToStreamAsync( + string streamName, + StreamState expectedState, + IEnumerable eventData, + Action? configureOperationOptions = null, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) { + var operationOptions = Settings.OperationOptions.Clone(); + configureOperationOptions?.Invoke(operationOptions); + + _log.LogDebug("Append to stream - {streamName}@{expectedState}.", streamName, expectedState); + + var task = + userCredentials == null && await BatchAppender.IsUsable().ConfigureAwait(false) + ? BatchAppender.Append(streamName, expectedState, eventData, deadline, cancellationToken) + : AppendToStreamInternal( + await GetChannelInfo(cancellationToken).ConfigureAwait(false), + new AppendReq { + Options = new() { + StreamIdentifier = streamName + } + }.WithAnyStreamRevision(expectedState), + eventData, + operationOptions, + deadline, + userCredentials, + cancellationToken + ); + + return (await task.ConfigureAwait(false)).OptionallyThrowWrongExpectedVersionException(operationOptions); + } + + ValueTask AppendToStreamInternal( + ChannelInfo channelInfo, + AppendReq header, + IEnumerable eventData, + KurrentClientOperationOptions operationOptions, + TimeSpan? deadline, + UserCredentials? userCredentials, + CancellationToken cancellationToken + ) { + var tags = new ActivityTagsCollection() + .WithRequiredTag(TelemetryTags.Kurrent.Stream, header.Options.StreamIdentifier.StreamName.ToStringUtf8()) + .WithGrpcChannelServerTags(channelInfo) + .WithClientSettingsServerTags(Settings) + .WithOptionalTag(TelemetryTags.Database.User, userCredentials?.Username ?? Settings.DefaultCredentials?.Username); + + return KurrentClientDiagnostics.ActivitySource.TraceClientOperation(Operation, TracingConstants.Operations.Append, tags); + + async ValueTask Operation() { + using var call = new StreamsClient(channelInfo.CallInvoker) + .Append(KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + await call.RequestStream + .WriteAsync(header) + .ConfigureAwait(false); + + foreach (var e in eventData) { + var appendReq = new AppendReq { + ProposedMessage = new() { + Id = e.EventId.ToDto(), + Data = ByteString.CopyFrom(e.Data.Span), + CustomMetadata = ByteString.CopyFrom(e.Metadata.InjectTracingContext(Activity.Current)), + Metadata = { + { Constants.Metadata.Type, e.Type }, + { Constants.Metadata.ContentType, e.ContentType } + } + } + }; + + await call.RequestStream.WriteAsync(appendReq).ConfigureAwait(false); + } + + await call.RequestStream.CompleteAsync().ConfigureAwait(false); + + var response = await call.ResponseAsync.ConfigureAwait(false); + + if (response.Success is not null) + return HandleSuccessAppend(response, header); + + if (response.WrongExpectedVersion is null) + throw new InvalidOperationException("The operation completed with an unexpected result."); + + return HandleWrongExpectedRevision(response, header, operationOptions); + } + } + + IWriteResult HandleSuccessAppend(AppendResp response, AppendReq header) { + var currentRevision = response.Success.CurrentRevisionOptionCase == AppendResp.Types.Success.CurrentRevisionOptionOneofCase.NoStream + ? StreamRevision.None + : new StreamRevision(response.Success.CurrentRevision); + + var position = response.Success.PositionOptionCase == AppendResp.Types.Success.PositionOptionOneofCase.Position + ? new Position(response.Success.Position.CommitPosition, response.Success.Position.PreparePosition) + : default; + + _log.LogDebug( + "Append to stream succeeded - {streamName}@{logPosition}/{nextExpectedVersion}.", + header.Options.StreamIdentifier, + position, + currentRevision + ); + + return new SuccessResult(currentRevision, position); + } + + IWriteResult HandleWrongExpectedRevision( + AppendResp response, AppendReq header, KurrentClientOperationOptions operationOptions + ) { + var actualStreamRevision = response.WrongExpectedVersion.CurrentRevisionOptionCase == CurrentRevisionOptionOneofCase.CurrentRevision + ? new StreamRevision(response.WrongExpectedVersion.CurrentRevision) + : StreamRevision.None; + + _log.LogDebug( + "Append to stream failed with Wrong Expected Version - {streamName}/{expectedRevision}/{currentRevision}", + header.Options.StreamIdentifier, + new StreamRevision(header.Options.Revision), + actualStreamRevision + ); + + if (operationOptions.ThrowOnAppendFailure) { + if (response.WrongExpectedVersion.ExpectedRevisionOptionCase == ExpectedRevisionOptionOneofCase.ExpectedRevision) { + throw new WrongExpectedVersionException( + header.Options.StreamIdentifier!, + new StreamRevision(response.WrongExpectedVersion.ExpectedRevision), + actualStreamRevision + ); + } + + var expectedStreamState = response.WrongExpectedVersion.ExpectedRevisionOptionCase switch { + ExpectedRevisionOptionOneofCase.ExpectedAny => StreamState.Any, + ExpectedRevisionOptionOneofCase.ExpectedNoStream => StreamState.NoStream, + ExpectedRevisionOptionOneofCase.ExpectedStreamExists => StreamState.StreamExists, + _ => StreamState.Any + }; + + throw new WrongExpectedVersionException( + header.Options.StreamIdentifier!, + expectedStreamState, + actualStreamRevision + ); + } + + var expectedRevision = response.WrongExpectedVersion.ExpectedRevisionOptionCase == ExpectedRevisionOptionOneofCase.ExpectedRevision + ? new StreamRevision(response.WrongExpectedVersion.ExpectedRevision) + : StreamRevision.None; + + return new WrongExpectedVersionResult( + header.Options.StreamIdentifier!, + expectedRevision, + actualStreamRevision + ); + } + + class StreamAppender : IDisposable { + readonly KurrentClientSettings _settings; + readonly CancellationToken _cancellationToken; + readonly Action _onException; + readonly Channel _channel; + readonly ConcurrentDictionary> _pendingRequests; + readonly TaskCompletionSource _isUsable; + + ChannelInfo? _channelInfo; + AsyncDuplexStreamingCall? _call; + + public StreamAppender( + KurrentClientSettings settings, + ValueTask channelInfoTask, + CancellationToken cancellationToken, + Action onException + ) { + _settings = settings; + _cancellationToken = cancellationToken; + _onException = onException; + _channel = Channel.CreateBounded(10000); + _pendingRequests = new ConcurrentDictionary>(); + _isUsable = new TaskCompletionSource(); + + _ = Task.Run(() => Duplex(channelInfoTask), cancellationToken); + } + + public ValueTask Append( + string streamName, StreamRevision expectedStreamPosition, + IEnumerable events, TimeSpan? timeoutAfter, + CancellationToken cancellationToken = default + ) => + AppendInternal( + BatchAppendReq.Types.Options.Create(streamName, expectedStreamPosition, timeoutAfter), + events, + cancellationToken + ); + + public ValueTask Append( + string streamName, StreamState expectedStreamState, + IEnumerable events, TimeSpan? timeoutAfter, + CancellationToken cancellationToken = default + ) => + AppendInternal( + BatchAppendReq.Types.Options.Create(streamName, expectedStreamState, timeoutAfter), + events, + cancellationToken + ); + + public Task IsUsable() => _isUsable.Task; + + ValueTask AppendInternal( + BatchAppendReq.Types.Options options, + IEnumerable events, + CancellationToken cancellationToken + ) { + var tags = new ActivityTagsCollection() + .WithRequiredTag(TelemetryTags.Kurrent.Stream, options.StreamIdentifier.StreamName.ToStringUtf8()) + .WithGrpcChannelServerTags(_channelInfo) + .WithClientSettingsServerTags(_settings) + .WithOptionalTag(TelemetryTags.Database.User, _settings.DefaultCredentials?.Username); + + return KurrentClientDiagnostics.ActivitySource.TraceClientOperation( + Operation, + TracingConstants.Operations.Append, + tags + ); + + async ValueTask Operation() { + var correlationId = Uuid.NewUuid(); + + var complete = _pendingRequests.GetOrAdd(correlationId, new TaskCompletionSource()); + + try { + foreach (var appendRequest in GetRequests(events, options, correlationId)) + await _channel.Writer.WriteAsync(appendRequest, cancellationToken).ConfigureAwait(false); + } + catch (ChannelClosedException ex) { + // channel is closed, our tcs won't necessarily get completed, don't wait for it. + throw ex.InnerException ?? ex; + } + + return await complete.Task.ConfigureAwait(false); + } + } + + async Task Duplex(ValueTask channelInfoTask) { + try { + _channelInfo = await channelInfoTask.ConfigureAwait(false); + if (!_channelInfo.ServerCapabilities.SupportsBatchAppend) { + _channel.Writer.TryComplete(new NotSupportedException("Server does not support batch append")); + _isUsable.TrySetResult(false); + return; + } + + _call = new StreamsClient(_channelInfo.CallInvoker).BatchAppend( + KurrentCallOptions.CreateStreaming( + _settings, + userCredentials: _settings.DefaultCredentials, + cancellationToken: _cancellationToken + ) + ); + + _ = Task.Run(Send, _cancellationToken); + _ = Task.Run(Receive, _cancellationToken); + + _isUsable.TrySetResult(true); + } + catch (Exception ex) { + _isUsable.TrySetException(ex); + _onException(ex); + } + + return; + + async Task Send() { + if (_call is null) return; + + await foreach (var appendRequest in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) + await _call.RequestStream.WriteAsync(appendRequest).ConfigureAwait(false); + + await _call.RequestStream.CompleteAsync().ConfigureAwait(false); + } + + async Task Receive() { + if (_call is null) return; + + try { + await foreach (var response in _call.ResponseStream.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { + if (!_pendingRequests.TryRemove(Uuid.FromDto(response.CorrelationId), out var writeResult)) { + continue; // TODO: Log? + } + + try { + writeResult.TrySetResult(response.ToWriteResult()); + } + catch (Exception ex) { + writeResult.TrySetException(ex); + } + } + } + catch (Exception ex) { + // signal that no tcs added to _pendingRequests after this point will necessarily complete + _channel.Writer.TryComplete(ex); + + // complete whatever tcs's we have + foreach (var request in _pendingRequests) + request.Value.TrySetException(ex); + + _onException(ex); + } + } + } + + IEnumerable GetRequests(IEnumerable events, BatchAppendReq.Types.Options options, Uuid correlationId) { + var batchSize = 0; + var first = true; + var correlationIdDto = correlationId.ToDto(); + var proposedMessages = new List(); + + foreach (var eventData in events) { + var proposedMessage = new BatchAppendReq.Types.ProposedMessage { + Data = ByteString.CopyFrom(eventData.Data.Span), + CustomMetadata = ByteString.CopyFrom(eventData.Metadata.InjectTracingContext(Activity.Current)), + Id = eventData.EventId.ToDto(), + Metadata = { + { Constants.Metadata.Type, eventData.Type }, + { Constants.Metadata.ContentType, eventData.ContentType } + } + }; + + proposedMessages.Add(proposedMessage); + + if ((batchSize += proposedMessage.CalculateSize()) < _settings.OperationOptions.BatchAppendSize) + continue; + + yield return new BatchAppendReq { + ProposedMessages = { proposedMessages }, + CorrelationId = correlationIdDto, + Options = first ? options : null + }; + + first = false; + proposedMessages.Clear(); + batchSize = 0; + } + + yield return new BatchAppendReq { + ProposedMessages = { proposedMessages }, + IsFinal = true, + CorrelationId = correlationIdDto, + Options = first ? options : null + }; + } + + public void Dispose() { + _channel.Writer.TryComplete(); + _call?.Dispose(); + } + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.Delete.cs b/src/Kurrent.Client/Streams/KurrentClient.Delete.cs new file mode 100644 index 000000000..50a174e25 --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.Delete.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Streams; +using Microsoft.Extensions.Logging; + +namespace EventStore.Client { + public partial class KurrentClient { + /// + /// Deletes a stream asynchronously. + /// + /// The name of the stream to delete. + /// The expected of the stream being deleted. + /// The maximum time to wait before terminating the call. + /// The optional to perform operation with. + /// The optional . + /// + public Task DeleteAsync( + string streamName, + StreamRevision expectedRevision, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => + DeleteInternal(new DeleteReq { + Options = new DeleteReq.Types.Options { + StreamIdentifier = streamName, + Revision = expectedRevision + } + }, deadline, userCredentials, cancellationToken); + + /// + /// Deletes a stream asynchronously. + /// + /// The name of the stream to delete. + /// The expected of the stream being deleted. + /// The maximum time to wait before terminating the call. + /// The optional to perform operation with. + /// The optional . + /// + public Task DeleteAsync( + string streamName, + StreamState expectedState, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => DeleteInternal(new DeleteReq { + Options = new DeleteReq.Types.Options { + StreamIdentifier = streamName + } + }.WithAnyStreamRevision(expectedState), deadline, userCredentials, cancellationToken); + + private async Task DeleteInternal(DeleteReq request, + TimeSpan? deadline, + UserCredentials? userCredentials, + CancellationToken cancellationToken) { + _log.LogDebug("Deleting stream {streamName}.", request.Options.StreamIdentifier); + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Streams.Streams.StreamsClient( + channelInfo.CallInvoker).DeleteAsync(request, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + var result = await call.ResponseAsync.ConfigureAwait(false); + + return new DeleteResult(new Position(result.Position.CommitPosition, result.Position.PreparePosition)); + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.Metadata.cs b/src/Kurrent.Client/Streams/KurrentClient.Metadata.cs new file mode 100644 index 000000000..f8185607c --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.Metadata.cs @@ -0,0 +1,106 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Streams; +using Microsoft.Extensions.Logging; + +namespace EventStore.Client { + public partial class KurrentClient { + /// + /// Asynchronously reads the metadata for a stream + /// + /// The name of the stream to read the metadata for. + /// + /// The optional to perform operation with. + /// The optional . + /// + public async Task GetStreamMetadataAsync(string streamName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + _log.LogDebug("Read stream metadata for {streamName}.", streamName); + + try { + var result = ReadStreamAsync(Direction.Backwards, SystemStreams.MetastreamOf(streamName), + StreamPosition.End, 1, false, deadline, userCredentials, cancellationToken); + await foreach (var message in result.Messages.ConfigureAwait(false)) { + if (message is not StreamMessage.Event(var resolvedEvent)) { + continue; + } + + return StreamMetadataResult.Create(streamName, resolvedEvent.OriginalEventNumber, + JsonSerializer.Deserialize(resolvedEvent.Event.Data.Span, + StreamMetadataJsonSerializerOptions)); + } + + } catch (StreamNotFoundException) { + } + _log.LogWarning("Stream metadata for {streamName} not found.", streamName); + return StreamMetadataResult.None(streamName); + } + + /// + /// Asynchronously sets the metadata for a stream. + /// + /// The name of the stream to set metadata for. + /// The of the stream to append to. + /// A representing the new metadata. + /// An to configure the operation's options. + /// + /// The optional to perform operation with. + /// The optional . + /// + public Task SetStreamMetadataAsync(string streamName, StreamState expectedState, + StreamMetadata metadata, Action? configureOperationOptions = null, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var options = Settings.OperationOptions.Clone(); + configureOperationOptions?.Invoke(options); + + return SetStreamMetadataInternal(metadata, new AppendReq { + Options = new AppendReq.Types.Options { + StreamIdentifier = SystemStreams.MetastreamOf(streamName) + } + }.WithAnyStreamRevision(expectedState), options, deadline, userCredentials, cancellationToken); + } + + /// + /// Asynchronously sets the metadata for a stream. + /// + /// The name of the stream to set metadata for. + /// The of the stream to append to. + /// A representing the new metadata. + /// An to configure the operation's options. + /// + /// The optional to perform operation with. + /// The optional . + /// + public Task SetStreamMetadataAsync(string streamName, StreamRevision expectedRevision, + StreamMetadata metadata, Action? configureOperationOptions = null, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + var options = Settings.OperationOptions.Clone(); + configureOperationOptions?.Invoke(options); + + return SetStreamMetadataInternal(metadata, new AppendReq { + Options = new AppendReq.Types.Options { + StreamIdentifier = SystemStreams.MetastreamOf(streamName), + Revision = expectedRevision + } + }, options, deadline, userCredentials, cancellationToken); + } + + private async Task SetStreamMetadataInternal(StreamMetadata metadata, + AppendReq appendReq, + KurrentClientOperationOptions operationOptions, + TimeSpan? deadline, + UserCredentials? userCredentials, + CancellationToken cancellationToken) { + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + return await AppendToStreamInternal(channelInfo, appendReq, new[] { + new EventData(Uuid.NewUuid(), SystemEventTypes.StreamMetadata, + JsonSerializer.SerializeToUtf8Bytes(metadata, StreamMetadataJsonSerializerOptions)), + }, operationOptions, deadline, userCredentials, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.Read.cs b/src/Kurrent.Client/Streams/KurrentClient.Read.cs new file mode 100644 index 000000000..0523d9516 --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.Read.cs @@ -0,0 +1,468 @@ +using System.Threading.Channels; +using EventStore.Client.Streams; +using Grpc.Core; +using static EventStore.Client.Streams.ReadResp; +using static EventStore.Client.Streams.ReadResp.ContentOneofCase; + +namespace EventStore.Client { + public partial class KurrentClient { + /// + /// Asynchronously reads all events. + /// + /// The in which to read. + /// The to start reading from. + /// The maximum count to read. + /// Whether to resolve LinkTo events automatically. + /// + /// The optional to perform operation with. + /// The optional . + /// + public ReadAllStreamResult ReadAllAsync( + Direction direction, + Position position, + long maxCount = long.MaxValue, + bool resolveLinkTos = false, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) => ReadAllAsync( + direction, + position, + eventFilter: null, + maxCount, + resolveLinkTos, + deadline, + userCredentials, + cancellationToken + ); + + /// + /// Asynchronously reads all events with filtering. + /// + /// The in which to read. + /// The to start reading from. + /// The to apply. + /// The maximum count to read. + /// Whether to resolve LinkTo events automatically. + /// + /// The optional to perform operation with. + /// The optional . + /// + public ReadAllStreamResult ReadAllAsync( + Direction direction, + Position position, + IEventFilter? eventFilter, + long maxCount = long.MaxValue, + bool resolveLinkTos = false, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) { + if (maxCount <= 0) + throw new ArgumentOutOfRangeException(nameof(maxCount)); + + var readReq = new ReadReq { + Options = new() { + ReadDirection = direction switch { + Direction.Backwards => ReadReq.Types.Options.Types.ReadDirection.Backwards, + Direction.Forwards => ReadReq.Types.Options.Types.ReadDirection.Forwards, + _ => throw InvalidOption(direction) + }, + ResolveLinks = resolveLinkTos, + All = new() { + Position = new() { + CommitPosition = position.CommitPosition, + PreparePosition = position.PreparePosition + } + }, + Count = (ulong)maxCount, + UuidOption = new() { Structured = new() }, + ControlOption = new() { Compatibility = 1 }, + Filter = GetFilterOptions(eventFilter) + } + }; + + return new ReadAllStreamResult( + async _ => { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + return channelInfo.CallInvoker; + }, + readReq, + Settings, + deadline, + userCredentials, + cancellationToken + ); + } + + /// + /// A class that represents the result of a read operation on the $all stream. You may either enumerate this instance directly or . Do not enumerate more than once. + /// + public class ReadAllStreamResult : IAsyncEnumerable { + readonly Channel _channel; + readonly CancellationTokenSource _cts; + + int _messagesEnumerated; + + /// + /// The last of the $all stream, if available. + /// + public Position? LastPosition { get; private set; } + + /// + /// An . Do not enumerate more than once. + /// + public IAsyncEnumerable Messages { + get { + return GetMessages(); + + async IAsyncEnumerable GetMessages() { + if (Interlocked.Exchange(ref _messagesEnumerated, 1) == 1) { + throw new InvalidOperationException("Messages may only be enumerated once."); + } + + try { + await foreach (var message in _channel.Reader.ReadAllAsync(_cts.Token) + .ConfigureAwait(false)) { + if (message is StreamMessage.LastAllStreamPosition(var position)) { + LastPosition = position; + } + + yield return message; + } + } + finally { + _cts.Cancel(); + } + } + } + } + + internal ReadAllStreamResult( + Func> selectCallInvoker, ReadReq request, + KurrentClientSettings settings, TimeSpan? deadline, UserCredentials? userCredentials, + CancellationToken cancellationToken + ) { + var callOptions = KurrentCallOptions.CreateStreaming( + settings, + deadline, + userCredentials, + cancellationToken + ); + + _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = _cts.Token; + + if (request.Options.FilterOptionCase == ReadReq.Types.Options.FilterOptionOneofCase.None) + request.Options.NoFilter = new(); + + _ = PumpMessages(); + + return; + + async Task PumpMessages() { + try { + var callInvoker = await selectCallInvoker(linkedCancellationToken).ConfigureAwait(false); + var client = new Streams.Streams.StreamsClient(callInvoker); + using var call = client.Read(request, callOptions); + await foreach (var response in call.ResponseStream.ReadAllAsync(linkedCancellationToken) + .ConfigureAwait(false)) { + await _channel.Writer.WriteAsync( + response.ContentCase switch { + StreamNotFound => StreamMessage.NotFound.Instance, + Event => new StreamMessage.Event(ConvertToResolvedEvent(response.Event)), + FirstStreamPosition => new StreamMessage.FirstStreamPosition(new StreamPosition(response.FirstStreamPosition)), + LastStreamPosition => new StreamMessage.LastStreamPosition(new StreamPosition(response.LastStreamPosition)), + LastAllStreamPosition => new StreamMessage.LastAllStreamPosition( + new Position( + response.LastAllStreamPosition.CommitPosition, + response.LastAllStreamPosition.PreparePosition + ) + ), + _ => StreamMessage.Unknown.Instance + }, + linkedCancellationToken + ).ConfigureAwait(false); + } + + _channel.Writer.Complete(); + } + catch (Exception ex) { + _channel.Writer.TryComplete(ex); + } + } + } + + /// + public async IAsyncEnumerator GetAsyncEnumerator( + CancellationToken cancellationToken = default + ) { + try { + await foreach (var message in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { + if (message is not StreamMessage.Event e) { + continue; + } + + yield return e.ResolvedEvent; + } + } + finally { + _cts.Cancel(); + } + } + } + + /// + /// Asynchronously reads all the events from a stream. + /// + /// The result could also be inspected as a means to avoid handling exceptions as the would indicate whether or not the stream is readable./> + /// + /// The in which to read. + /// The name of the stream to read. + /// The to start reading from. + /// The number of events to read from the stream. + /// Whether to resolve LinkTo events automatically. + /// + /// The optional to perform operation with. + /// The optional . + /// + public ReadStreamResult ReadStreamAsync( + Direction direction, + string streamName, + StreamPosition revision, + long maxCount = long.MaxValue, + bool resolveLinkTos = false, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) { + if (maxCount <= 0) + throw new ArgumentOutOfRangeException(nameof(maxCount)); + + return new ReadStreamResult( + async _ => { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + return channelInfo.CallInvoker; + }, + new ReadReq { + Options = new() { + ReadDirection = direction switch { + Direction.Backwards => ReadReq.Types.Options.Types.ReadDirection.Backwards, + Direction.Forwards => ReadReq.Types.Options.Types.ReadDirection.Forwards, + _ => throw InvalidOption(direction) + }, + ResolveLinks = resolveLinkTos, + Stream = ReadReq.Types.Options.Types.StreamOptions.FromStreamNameAndRevision( + streamName, + revision + ), + Count = (ulong)maxCount, + UuidOption = new() { Structured = new() }, + NoFilter = new(), + ControlOption = new() { Compatibility = 1 } + } + }, + Settings, + deadline, + userCredentials, + cancellationToken + ); + } + + /// + /// A class that represents the result of a read operation on a stream. You may either enumerate this instance directly or . Do not enumerate more than once. + /// + public class ReadStreamResult : IAsyncEnumerable { + readonly Channel _channel; + readonly CancellationTokenSource _cts; + + int _messagesEnumerated; + + /// + /// The name of the stream. + /// + public string StreamName { get; } + + /// + /// The of the first message in this stream. Will only be filled once has been enumerated. + /// + public StreamPosition? FirstStreamPosition { get; private set; } + + /// + /// The of the last message in this stream. Will only be filled once has been enumerated. + /// + public StreamPosition? LastStreamPosition { get; private set; } + + /// + /// An . Do not enumerate more than once. + /// + public IAsyncEnumerable Messages { + get { + return GetMessages(); + + async IAsyncEnumerable GetMessages() { + if (Interlocked.Exchange(ref _messagesEnumerated, 1) == 1) { + throw new InvalidOperationException("Messages may only be enumerated once."); + } + + try { + await foreach (var message in _channel.Reader.ReadAllAsync(_cts.Token).ConfigureAwait(false)) { + switch (message) { + case StreamMessage.FirstStreamPosition(var streamPosition): + FirstStreamPosition = streamPosition; + break; + + case StreamMessage.LastStreamPosition(var lastStreamPosition): + LastStreamPosition = lastStreamPosition; + break; + + default: + break; + } + + yield return message; + } + } + finally { + _cts.Cancel(); + } + } + } + } + + /// + /// The . + /// + public Task ReadState { get; } + + internal ReadStreamResult( + Func> selectCallInvoker, ReadReq request, + KurrentClientSettings settings, TimeSpan? deadline, UserCredentials? userCredentials, + CancellationToken cancellationToken + ) { + var callOptions = KurrentCallOptions.CreateStreaming( + settings, + deadline, + userCredentials, + cancellationToken + ); + + _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + + StreamName = request.Options.Stream.StreamIdentifier!; + + var tcs = new TaskCompletionSource(); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedCancellationToken = _cts.Token; +#pragma warning disable CS0612 + ReadState = tcs.Task; +#pragma warning restore CS0612 + + _ = PumpMessages(); + + return; + + async Task PumpMessages() { + var firstMessageRead = false; + + try { + var callInvoker = await selectCallInvoker(linkedCancellationToken).ConfigureAwait(false); + var client = new Streams.Streams.StreamsClient(callInvoker); + using var call = client.Read(request, callOptions); + + await foreach (var response in call.ResponseStream.ReadAllAsync(linkedCancellationToken) + .ConfigureAwait(false)) { + if (!firstMessageRead) { + firstMessageRead = true; + + if (response.ContentCase != StreamNotFound || request.Options.Stream == null) { + await _channel.Writer.WriteAsync(StreamMessage.Ok.Instance, linkedCancellationToken) + .ConfigureAwait(false); + + tcs.SetResult(Client.ReadState.Ok); + } + else { + tcs.SetResult(Client.ReadState.StreamNotFound); + } + } + + await _channel.Writer.WriteAsync( + response.ContentCase switch { + StreamNotFound => StreamMessage.NotFound.Instance, + Event => new StreamMessage.Event(ConvertToResolvedEvent(response.Event)), + ContentOneofCase.FirstStreamPosition => new StreamMessage.FirstStreamPosition( + new StreamPosition(response.FirstStreamPosition) + ), + ContentOneofCase.LastStreamPosition => new StreamMessage.LastStreamPosition( + new StreamPosition(response.LastStreamPosition) + ), + LastAllStreamPosition => new StreamMessage.LastAllStreamPosition( + new Position( + response.LastAllStreamPosition.CommitPosition, + response.LastAllStreamPosition.PreparePosition + ) + ), + _ => StreamMessage.Unknown.Instance + }, + linkedCancellationToken + ).ConfigureAwait(false); + } + + _channel.Writer.Complete(); + } + catch (Exception ex) { + tcs.TrySetException(ex); + _channel.Writer.TryComplete(ex); + } + } + } + + /// + public async IAsyncEnumerator GetAsyncEnumerator( + CancellationToken cancellationToken = default + ) { + try { + await foreach (var message in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { + if (message is StreamMessage.NotFound) { + throw new StreamNotFoundException(StreamName); + } + + if (message is not StreamMessage.Event e) { + continue; + } + + yield return e.ResolvedEvent; + } + } + finally { + _cts.Cancel(); + } + } + } + + static ResolvedEvent ConvertToResolvedEvent(ReadResp.Types.ReadEvent readEvent) => + new ResolvedEvent( + ConvertToEventRecord(readEvent.Event)!, + ConvertToEventRecord(readEvent.Link), + readEvent.PositionCase switch { + ReadResp.Types.ReadEvent.PositionOneofCase.CommitPosition => readEvent.CommitPosition, + _ => null + } + ); + + static EventRecord? ConvertToEventRecord(ReadResp.Types.ReadEvent.Types.RecordedEvent? e) => + e == null + ? null + : new EventRecord( + e.StreamIdentifier!, + Uuid.FromDto(e.Id), + new StreamPosition(e.StreamRevision), + new Position(e.CommitPosition, e.PreparePosition), + e.Metadata, + e.Data.ToByteArray(), + e.CustomMetadata.ToByteArray() + ); + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.Subscriptions.cs b/src/Kurrent.Client/Streams/KurrentClient.Subscriptions.cs new file mode 100644 index 000000000..92adb172b --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.Subscriptions.cs @@ -0,0 +1,302 @@ +using System.Threading.Channels; +using EventStore.Client.Diagnostics; +using EventStore.Client.Streams; +using Grpc.Core; + +using static EventStore.Client.Streams.ReadResp.ContentOneofCase; + +namespace EventStore.Client { + public partial class KurrentClient { + /// + /// Subscribes to all events. + /// + /// A (exclusive of) to start the subscription from. + /// A Task invoked and awaited when a new event is received over the subscription. + /// Whether to resolve LinkTo events automatically. + /// An action invoked if the subscription is dropped. + /// The optional to apply. + /// The optional user credentials to perform operation with. + /// The optional . + /// + public Task SubscribeToAllAsync( + FromAll start, + Func eventAppeared, + bool resolveLinkTos = false, + Action? subscriptionDropped = default, + SubscriptionFilterOptions? filterOptions = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) => StreamSubscription.Confirm( + SubscribeToAll(start, resolveLinkTos, filterOptions, userCredentials, cancellationToken), + eventAppeared, + subscriptionDropped, + _log, + filterOptions?.CheckpointReached, + cancellationToken: cancellationToken + ); + + /// + /// Subscribes to all events. + /// + /// A (exclusive of) to start the subscription from. + /// Whether to resolve LinkTo events automatically. + /// The optional to apply. + /// The optional user credentials to perform operation with. + /// The optional . + /// + public StreamSubscriptionResult SubscribeToAll( + FromAll start, + bool resolveLinkTos = false, + SubscriptionFilterOptions? filterOptions = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) => new( + async _ => await GetChannelInfo(cancellationToken).ConfigureAwait(false), + new ReadReq { + Options = new ReadReq.Types.Options { + ReadDirection = ReadReq.Types.Options.Types.ReadDirection.Forwards, + ResolveLinks = resolveLinkTos, + All = ReadReq.Types.Options.Types.AllOptions.FromSubscriptionPosition(start), + Subscription = new ReadReq.Types.Options.Types.SubscriptionOptions(), + Filter = GetFilterOptions(filterOptions)!, + UuidOption = new() { Structured = new() } + } + }, + Settings, + userCredentials, + cancellationToken + ); + + /// + /// Subscribes to a stream from a checkpoint. + /// + /// A (exclusive of) to start the subscription from. + /// The name of the stream to read events from. + /// A Task invoked and awaited when a new event is received over the subscription. + /// Whether to resolve LinkTo events automatically. + /// An action invoked if the subscription is dropped. + /// The optional user credentials to perform operation with. + /// The optional . + /// + public Task SubscribeToStreamAsync( + string streamName, + FromStream start, + Func eventAppeared, + bool resolveLinkTos = false, + Action? subscriptionDropped = default, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) => StreamSubscription.Confirm( + SubscribeToStream(streamName, start, resolveLinkTos, userCredentials, cancellationToken), + eventAppeared, + subscriptionDropped, + _log, + cancellationToken: cancellationToken + ); + + /// + /// Subscribes to a stream from a checkpoint. + /// + /// A (exclusive of) to start the subscription from. + /// The name of the stream to read events from. + /// Whether to resolve LinkTo events automatically. + /// The optional user credentials to perform operation with. + /// The optional . + /// + public StreamSubscriptionResult SubscribeToStream( + string streamName, + FromStream start, + bool resolveLinkTos = false, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default + ) => new( + async _ => await GetChannelInfo(cancellationToken).ConfigureAwait(false), + new ReadReq { + Options = new ReadReq.Types.Options { + ReadDirection = ReadReq.Types.Options.Types.ReadDirection.Forwards, + ResolveLinks = resolveLinkTos, + Stream = ReadReq.Types.Options.Types.StreamOptions.FromSubscriptionPosition(streamName, start), + Subscription = new ReadReq.Types.Options.Types.SubscriptionOptions(), + UuidOption = new() { Structured = new() } + } + }, + Settings, + userCredentials, + cancellationToken + ); + + /// + /// A class that represents the result of a subscription operation. You may either enumerate this instance directly or . Do not enumerate more than once. + /// + public class StreamSubscriptionResult : IAsyncEnumerable, IAsyncDisposable, IDisposable { + private readonly ReadReq _request; + private readonly Channel _channel; + private readonly CancellationTokenSource _cts; + private readonly CallOptions _callOptions; + private readonly KurrentClientSettings _settings; + private AsyncServerStreamingCall? _call; + + private int _messagesEnumerated; + + /// + /// The server-generated unique identifier for the subscription. + /// + public string? SubscriptionId { get; private set; } + + /// + /// An . Do not enumerate more than once. + /// + public IAsyncEnumerable Messages { + get { + if (Interlocked.Exchange(ref _messagesEnumerated, 1) == 1) + throw new InvalidOperationException("Messages may only be enumerated once."); + + return GetMessages(); + + async IAsyncEnumerable GetMessages() { + try { + await foreach (var message in _channel.Reader.ReadAllAsync(_cts.Token)) { + if (message is StreamMessage.SubscriptionConfirmation(var subscriptionId)) + SubscriptionId = subscriptionId; + + yield return message; + } + } + finally { +#if NET8_0_OR_GREATER + await _cts.CancelAsync().ConfigureAwait(false); +#else + _cts.Cancel(); +#endif + } + } + } + } + + internal StreamSubscriptionResult( + Func> selectChannelInfo, + ReadReq request, KurrentClientSettings settings, UserCredentials? userCredentials, + CancellationToken cancellationToken + ) { + _request = request; + _settings = settings; + + _callOptions = KurrentCallOptions.CreateStreaming( + settings, + userCredentials: userCredentials, + cancellationToken: cancellationToken + ); + + _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + if (_request.Options.FilterOptionCase == ReadReq.Types.Options.FilterOptionOneofCase.None) { + _request.Options.NoFilter = new(); + } + + _ = PumpMessages(); + + return; + + async Task PumpMessages() { + try { + var channelInfo = await selectChannelInfo(_cts.Token).ConfigureAwait(false); + var client = new Streams.Streams.StreamsClient(channelInfo.CallInvoker); + _call = client.Read(_request, _callOptions); + await foreach (var response in _call.ResponseStream.ReadAllAsync(_cts.Token).ConfigureAwait(false)) { + StreamMessage subscriptionMessage = + response.ContentCase switch { + Confirmation => new StreamMessage.SubscriptionConfirmation(response.Confirmation.SubscriptionId), + Event => new StreamMessage.Event(ConvertToResolvedEvent(response.Event)), + FirstStreamPosition => new StreamMessage.FirstStreamPosition(new StreamPosition(response.FirstStreamPosition)), + LastStreamPosition => new StreamMessage.LastStreamPosition(new StreamPosition(response.LastStreamPosition)), + LastAllStreamPosition => new StreamMessage.LastAllStreamPosition( + new Position( + response.LastAllStreamPosition.CommitPosition, + response.LastAllStreamPosition.PreparePosition + ) + ), + Checkpoint => new StreamMessage.AllStreamCheckpointReached( + new Position( + response.Checkpoint.CommitPosition, + response.Checkpoint.PreparePosition + ) + ), + CaughtUp => StreamMessage.CaughtUp.Instance, + FellBehind => StreamMessage.FellBehind.Instance, + _ => StreamMessage.Unknown.Instance + }; + + if (subscriptionMessage is StreamMessage.Event evt) + KurrentClientDiagnostics.ActivitySource.TraceSubscriptionEvent( + SubscriptionId, + evt.ResolvedEvent, + channelInfo, + _settings, + userCredentials + ); + + await _channel.Writer + .WriteAsync(subscriptionMessage, _cts.Token) + .ConfigureAwait(false); + } + + _channel.Writer.Complete(); + } catch (Exception ex) { + _channel.Writer.TryComplete(ex); + } + } + } + + /// + public async ValueTask DisposeAsync() { + //TODO SS: Check if `CastAndDispose` is still relevant + await CastAndDispose(_cts).ConfigureAwait(false); + await CastAndDispose(_call).ConfigureAwait(false); + + return; + + static async ValueTask CastAndDispose(IDisposable? resource) { + switch (resource) { + case null: + return; + + case IAsyncDisposable disposable: + await disposable.DisposeAsync().ConfigureAwait(false); + break; + + default: + resource.Dispose(); + break; + } + } + } + + /// + public void Dispose() { + _cts.Dispose(); + _call?.Dispose(); + } + + /// + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { + try { + await foreach (var message in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { + if (message is not StreamMessage.Event e) + continue; + + yield return e.ResolvedEvent; + } + } + finally { +#if NET8_0_OR_GREATER + await _cts.CancelAsync().ConfigureAwait(false); +#else + _cts.Cancel(); +#endif + } + } + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.Tombstone.cs b/src/Kurrent.Client/Streams/KurrentClient.Tombstone.cs new file mode 100644 index 000000000..26d62dad9 --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.Tombstone.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Client.Streams; +using Microsoft.Extensions.Logging; + +namespace EventStore.Client { + public partial class KurrentClient { + /// + /// Tombstones a stream asynchronously. Note: Tombstoned streams can never be recreated. + /// + /// The name of the stream to tombstone. + /// The expected of the stream being deleted. + /// + /// The optional to perform operation with. + /// The optional . + /// + public Task TombstoneAsync( + string streamName, + StreamRevision expectedRevision, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => TombstoneInternal(new TombstoneReq { + Options = new TombstoneReq.Types.Options { + StreamIdentifier = streamName, + Revision = expectedRevision + } + }, deadline, userCredentials, cancellationToken); + + /// + /// Tombstones a stream asynchronously. Note: Tombstoned streams can never be recreated. + /// + /// The name of the stream to tombstone. + /// The expected of the stream being deleted. + /// + /// The optional to perform operation with. + /// The optional . + /// + public Task TombstoneAsync( + string streamName, + StreamState expectedState, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) => TombstoneInternal(new TombstoneReq { + Options = new TombstoneReq.Types.Options { + StreamIdentifier = streamName + } + }.WithAnyStreamRevision(expectedState), deadline, userCredentials, cancellationToken); + + private async Task TombstoneInternal(TombstoneReq request, TimeSpan? deadline, + UserCredentials? userCredentials, CancellationToken cancellationToken) { + _log.LogDebug("Tombstoning stream {streamName}.", request.Options.StreamIdentifier); + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Streams.Streams.StreamsClient( + channelInfo.CallInvoker).TombstoneAsync(request, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + var result = await call.ResponseAsync.ConfigureAwait(false); + + return new DeleteResult(new Position(result.Position.CommitPosition, result.Position.PreparePosition)); + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClient.cs b/src/Kurrent.Client/Streams/KurrentClient.cs new file mode 100644 index 000000000..3dccf53ee --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClient.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using System.Threading.Channels; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ReadReq = EventStore.Client.Streams.ReadReq; + +namespace EventStore.Client { + /// + /// The client used for operations on streams. + /// + public sealed partial class KurrentClient : KurrentClientBase { + static readonly JsonSerializerOptions StreamMetadataJsonSerializerOptions = new() { + Converters = { + StreamMetadataJsonConverter.Instance + }, + }; + + static BoundedChannelOptions ReadBoundedChannelOptions = new(1) { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = true + }; + + readonly ILogger _log; + Lazy _batchAppenderLazy; + StreamAppender BatchAppender => _batchAppenderLazy.Value; + readonly CancellationTokenSource _disposedTokenSource; + + static readonly Dictionary> ExceptionMap = new() { + [Constants.Exceptions.InvalidTransaction] = ex => new InvalidTransactionException(ex.Message, ex), + [Constants.Exceptions.StreamDeleted] = ex => new StreamDeletedException( + ex.Trailers.FirstOrDefault(x => x.Key == Constants.Exceptions.StreamName)?.Value ?? "", + ex + ), + [Constants.Exceptions.WrongExpectedVersion] = ex => new WrongExpectedVersionException( + ex.Trailers.FirstOrDefault(x => x.Key == Constants.Exceptions.StreamName)?.Value!, + ex.Trailers.GetStreamRevision(Constants.Exceptions.ExpectedVersion), + ex.Trailers.GetStreamRevision(Constants.Exceptions.ActualVersion), + ex, + ex.Message + ), + [Constants.Exceptions.MaximumAppendSizeExceeded] = ex => new MaximumAppendSizeExceededException( + ex.Trailers.GetIntValueOrDefault(Constants.Exceptions.MaximumAppendSize), + ex + ), + [Constants.Exceptions.StreamNotFound] = ex => new StreamNotFoundException( + ex.Trailers.FirstOrDefault(x => x.Key == Constants.Exceptions.StreamName)?.Value!, + ex + ), + [Constants.Exceptions.MissingRequiredMetadataProperty] = ex => new RequiredMetadataPropertyMissingException( + ex.Trailers.FirstOrDefault(x => x.Key == Constants.Exceptions.MissingRequiredMetadataProperty)?.Value!, + ex + ), + }; + + /// + /// Constructs a new . This is not intended to be called directly from your code. + /// + /// + public KurrentClient(IOptions options) : this(options.Value) { } + + /// + /// Constructs a new . + /// + /// + public KurrentClient(KurrentClientSettings? settings = null) : base(settings, ExceptionMap) { + _log = Settings.LoggerFactory?.CreateLogger() ?? new NullLogger(); + _disposedTokenSource = new CancellationTokenSource(); + _batchAppenderLazy = new Lazy(CreateStreamAppender); + } + + void SwapStreamAppender(Exception ex) => + Interlocked.Exchange(ref _batchAppenderLazy, new Lazy(CreateStreamAppender)).Value + .Dispose(); + + // todo: might be nice to have two different kinds of appenders and we decide which to instantiate according to the server caps. + StreamAppender CreateStreamAppender() => new StreamAppender( + Settings, + GetChannelInfo(_disposedTokenSource.Token), + _disposedTokenSource.Token, + SwapStreamAppender + ); + + static ReadReq.Types.Options.Types.FilterOptions? GetFilterOptions( + IEventFilter? filter, uint checkpointInterval = 0 + ) { + if (filter == null + || filter.Equals(StreamFilter.None) + || filter.Equals(EventTypeFilter.None)) + return null; + + var options = filter switch { + StreamFilter => new ReadReq.Types.Options.Types.FilterOptions { + StreamIdentifier = (filter.Prefixes, filter.Regex) switch { + (_, _) + when (filter.Prefixes?.Length ?? 0) == 0 && + filter.Regex != RegularFilterExpression.None => + new ReadReq.Types.Options.Types.FilterOptions.Types.Expression + { Regex = filter.Regex }, + (_, _) + when (filter.Prefixes?.Length ?? 0) != 0 && + filter.Regex == RegularFilterExpression.None => + new ReadReq.Types.Options.Types.FilterOptions.Types.Expression { + Prefix = { Array.ConvertAll(filter.Prefixes!, e => e.ToString()) } + }, + _ => throw new InvalidOperationException() + } + }, + EventTypeFilter => new ReadReq.Types.Options.Types.FilterOptions { + EventType = (filter.Prefixes, filter.Regex) switch { + (_, _) + when (filter.Prefixes?.Length ?? 0) == 0 && + filter.Regex != RegularFilterExpression.None => + new ReadReq.Types.Options.Types.FilterOptions.Types.Expression + { Regex = filter.Regex }, + (_, _) + when (filter.Prefixes?.Length ?? 0) != 0 && + filter.Regex == RegularFilterExpression.None => + new ReadReq.Types.Options.Types.FilterOptions.Types.Expression { + Prefix = { Array.ConvertAll(filter.Prefixes!, e => e.ToString()) } + }, + _ => throw new InvalidOperationException() + } + }, + _ => null + }; + + if (options == null) + return null; + + if (filter.MaxSearchWindow.HasValue) + options.Max = filter.MaxSearchWindow.Value; + else + options.Count = new Empty(); + + options.CheckpointIntervalMultiplier = checkpointInterval; + + return options; + } + + static ReadReq.Types.Options.Types.FilterOptions? GetFilterOptions( + SubscriptionFilterOptions? filterOptions + ) + => filterOptions == null ? null : GetFilterOptions(filterOptions.Filter, filterOptions.CheckpointInterval); + + /// + public override void Dispose() { + if (_batchAppenderLazy.IsValueCreated) + _batchAppenderLazy.Value.Dispose(); + + _disposedTokenSource.Dispose(); + base.Dispose(); + } + + /// + public override async ValueTask DisposeAsync() { + if (_batchAppenderLazy.IsValueCreated) + _batchAppenderLazy.Value.Dispose(); + + _disposedTokenSource.Dispose(); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClientExtensions.cs b/src/Kurrent.Client/Streams/KurrentClientExtensions.cs new file mode 100644 index 000000000..690fa89bd --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClientExtensions.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace EventStore.Client { + /// + /// A set of extension methods for an . + /// + public static class KurrentClientExtensions { + private static readonly JsonSerializerOptions SystemSettingsJsonSerializerOptions = new JsonSerializerOptions { + Converters = { + SystemSettingsJsonConverter.Instance + }, + }; + + /// + /// Writes to the $settings stream. + /// + /// + /// + /// + /// + /// + /// + /// + public static Task SetSystemSettingsAsync( + this KurrentClient client, + SystemSettings settings, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (client == null) throw new ArgumentNullException(nameof(client)); + return client.AppendToStreamAsync(SystemStreams.SettingsStream, StreamState.Any, + new[] { + new EventData(Uuid.NewUuid(), SystemEventTypes.Settings, + JsonSerializer.SerializeToUtf8Bytes(settings, SystemSettingsJsonSerializerOptions)) + }, deadline: deadline, userCredentials: userCredentials, cancellationToken: cancellationToken); + } + + /// + /// Appends to a stream conditionally. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task ConditionalAppendToStreamAsync( + this KurrentClient client, + string streamName, + StreamRevision expectedRevision, + IEnumerable eventData, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (client == null) { + throw new ArgumentNullException(nameof(client)); + } + try { + var result = await client.AppendToStreamAsync(streamName, expectedRevision, eventData, + options => options.ThrowOnAppendFailure = false, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + return ConditionalWriteResult.FromWriteResult(result); + } catch (StreamDeletedException) { + return ConditionalWriteResult.StreamDeleted; + } + } + + /// + /// Appends to a stream conditionally. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task ConditionalAppendToStreamAsync( + this KurrentClient client, + string streamName, + StreamState expectedState, + IEnumerable eventData, + TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (client == null) { + throw new ArgumentNullException(nameof(client)); + } + try { + var result = await client.AppendToStreamAsync(streamName, expectedState, eventData, + options => options.ThrowOnAppendFailure = false, deadline, userCredentials, cancellationToken) + .ConfigureAwait(false); + return ConditionalWriteResult.FromWriteResult(result); + } catch (StreamDeletedException) { + return ConditionalWriteResult.StreamDeleted; + } catch (WrongExpectedVersionException ex) { + return ConditionalWriteResult.FromWrongExpectedVersion(ex); + } + } + } +} diff --git a/src/Kurrent.Client/Streams/KurrentClientServiceCollectionExtensions.cs b/src/Kurrent.Client/Streams/KurrentClientServiceCollectionExtensions.cs new file mode 100644 index 000000000..b2832fa56 --- /dev/null +++ b/src/Kurrent.Client/Streams/KurrentClientServiceCollectionExtensions.cs @@ -0,0 +1,153 @@ +// ReSharper disable CheckNamespace + +using System; +using System.Net.Http; +using EventStore.Client; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.DependencyInjection { + /// + /// A set of extension methods for which provide support for an . + /// + public static class KurrentClientServiceCollectionExtensions { + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentClient(this IServiceCollection services, Uri address, + Func? createHttpMessageHandler = null) + => services.AddKurrentClient(options => { + options.ConnectivitySettings.Address = address; + options.CreateHttpMessageHandler = createHttpMessageHandler; + }); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentClient(this IServiceCollection services, + Func addressFactory, + Func? createHttpMessageHandler = null) + => services.AddKurrentClient(provider => options => { + options.ConnectivitySettings.Address = addressFactory(provider); + options.CreateHttpMessageHandler = createHttpMessageHandler; + }); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentClient(this IServiceCollection services, + Action? configureSettings = null) => + services.AddKurrentClient(new KurrentClientSettings(), configureSettings); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentClient(this IServiceCollection services, + Func> configureSettings) => + services.AddKurrentClient(new KurrentClientSettings(), + configureSettings); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentClient(this IServiceCollection services, + string connectionString, Action? configureSettings = null) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + + return services.AddKurrentClient(KurrentClientSettings.Create(connectionString), configureSettings); + } + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentClient(this IServiceCollection services, + Func connectionStringFactory, + Action? configureSettings = null) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + + return services.AddKurrentClient(provider => KurrentClientSettings.Create(connectionStringFactory(provider)), configureSettings); + } + + private static IServiceCollection AddKurrentClient(this IServiceCollection services, + KurrentClientSettings settings, + Action? configureSettings) { + configureSettings?.Invoke(settings); + + services.TryAddSingleton(provider => { + settings.LoggerFactory ??= provider.GetService(); + settings.Interceptors ??= provider.GetServices(); + + return new KurrentClient(settings); + }); + + return services; + } + + private static IServiceCollection AddKurrentClient(this IServiceCollection services, + Func settingsFactory, + Action? configureSettings = null) { + + services.TryAddSingleton(provider => { + var settings = settingsFactory(provider); + configureSettings?.Invoke(settings); + + settings.LoggerFactory ??= provider.GetService(); + settings.Interceptors ??= provider.GetServices(); + + return new KurrentClient(settings); + }); + + return services; + } + + private static IServiceCollection AddKurrentClient(this IServiceCollection services, + KurrentClientSettings settings, + Func> configureSettingsFactory) { + + services.TryAddSingleton(provider => { + configureSettingsFactory(provider).Invoke(settings); + + settings.LoggerFactory ??= provider.GetService(); + settings.Interceptors ??= provider.GetServices(); + + return new KurrentClient(settings); + }); + + return services; + } + } +} +// ReSharper restore CheckNamespace diff --git a/src/Kurrent.Client/Streams/MaximumAppendSizeExceededException.cs b/src/Kurrent.Client/Streams/MaximumAppendSizeExceededException.cs new file mode 100644 index 000000000..7c785b951 --- /dev/null +++ b/src/Kurrent.Client/Streams/MaximumAppendSizeExceededException.cs @@ -0,0 +1,33 @@ +using System; + +namespace EventStore.Client { + /// + /// Exception thrown when an append exceeds the maximum size set by the server. + /// + public class MaximumAppendSizeExceededException : Exception { + /// + /// The configured maximum append size. + /// + public uint MaxAppendSize { get; } + + /// + /// Constructs a new . + /// + /// + /// + public MaximumAppendSizeExceededException(uint maxAppendSize, Exception? innerException = null) : + base($"Maximum Append Size of {maxAppendSize} Exceeded.", innerException) { + MaxAppendSize = maxAppendSize; + } + + /// + /// Constructs a new . + /// + /// + /// + public MaximumAppendSizeExceededException(int maxAppendSize, Exception? innerException = null) : this( + (uint)maxAppendSize, innerException) { + + } + } +} diff --git a/src/Kurrent.Client/Streams/ReadState.cs b/src/Kurrent.Client/Streams/ReadState.cs new file mode 100644 index 000000000..6f0497081 --- /dev/null +++ b/src/Kurrent.Client/Streams/ReadState.cs @@ -0,0 +1,15 @@ +namespace EventStore.Client { + /// + /// An enumeration representing the state of a read operation. + /// + public enum ReadState { + /// + /// The stream does not exist. + /// + StreamNotFound, + /// + /// The stream exists. + /// + Ok + } +} diff --git a/src/Kurrent.Client/Streams/StreamAcl.cs b/src/Kurrent.Client/Streams/StreamAcl.cs new file mode 100644 index 000000000..60f70a669 --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamAcl.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; + +namespace EventStore.Client { + /// + /// Represents an access control list for a stream + /// + public sealed class StreamAcl { + /// + /// Roles and users permitted to read the stream + /// + public string[]? ReadRoles { get; } + + /// + /// Roles and users permitted to write to the stream + /// + public string[]? WriteRoles { get; } + + /// + /// Roles and users permitted to delete the stream + /// + public string[]? DeleteRoles { get; } + + /// + /// Roles and users permitted to read stream metadata + /// + public string[]? MetaReadRoles { get; } + + /// + /// Roles and users permitted to write stream metadata + /// + public string[]? MetaWriteRoles { get; } + + + /// + /// Creates a new Stream Access Control List + /// + /// Role and user permitted to read the stream + /// Role and user permitted to write to the stream + /// Role and user permitted to delete the stream + /// Role and user permitted to read stream metadata + /// Role and user permitted to write stream metadata + public StreamAcl(string? readRole = null, string? writeRole = null, string? deleteRole = null, + string? metaReadRole = null, string? metaWriteRole = null) + : this(readRole == null ? null : new[] {readRole}, + writeRole == null ? null : new[] {writeRole}, + deleteRole == null ? null : new[] {deleteRole}, + metaReadRole == null ? null : new[] {metaReadRole}, + metaWriteRole == null ? null : new[] {metaWriteRole}) { + } + + /// + /// + /// + /// Roles and users permitted to read the stream + /// Roles and users permitted to write to the stream + /// Roles and users permitted to delete the stream + /// Roles and users permitted to read stream metadata + /// Roles and users permitted to write stream metadata + public StreamAcl(string[]? readRoles = null, string[]? writeRoles = null, string[]? deleteRoles = null, + string[]? metaReadRoles = null, string[]? metaWriteRoles = null) { + ReadRoles = readRoles; + WriteRoles = writeRoles; + DeleteRoles = deleteRoles; + MetaReadRoles = metaReadRoles; + MetaWriteRoles = metaWriteRoles; + } + + private bool Equals(StreamAcl other) => + (ReadRoles ?? Array.Empty()).SequenceEqual(other.ReadRoles ?? Array.Empty()) && + (WriteRoles ?? Array.Empty()).SequenceEqual(other.WriteRoles ?? Array.Empty()) && + (DeleteRoles ?? Array.Empty()).SequenceEqual(other.DeleteRoles ?? Array.Empty()) && + (MetaReadRoles ?? Array.Empty()).SequenceEqual(other.MetaReadRoles ?? Array.Empty()) && + (MetaWriteRoles ?? Array.Empty()).SequenceEqual(other.MetaWriteRoles ?? Array.Empty()); + + /// + public override bool Equals(object? obj) => + !ReferenceEquals(null, obj) && + (ReferenceEquals(this, obj) || obj.GetType() == GetType() && Equals((StreamAcl)obj)); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(StreamAcl? left, StreamAcl? right) => Equals(left, right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(StreamAcl? left, StreamAcl? right) => !Equals(left, right); + + /// + public override int GetHashCode() => + HashCode.Hash.Combine(ReadRoles).Combine(WriteRoles).Combine(DeleteRoles).Combine(MetaReadRoles) + .Combine(MetaWriteRoles); + + + /// + public override string ToString() => + $"Read: {(ReadRoles == null ? "" : "[" + string.Join(",", ReadRoles) + "]")}, Write: {(WriteRoles == null ? "" : "[" + string.Join(",", WriteRoles) + "]")}, Delete: {(DeleteRoles == null ? "" : "[" + string.Join(",", DeleteRoles) + "]")}, MetaRead: {(MetaReadRoles == null ? "" : "[" + string.Join(",", MetaReadRoles) + "]")}, MetaWrite: {(MetaWriteRoles == null ? "" : "[" + string.Join(",", MetaWriteRoles) + "]")}"; + } +} diff --git a/src/Kurrent.Client/Streams/StreamAclJsonConverter.cs b/src/Kurrent.Client/Streams/StreamAclJsonConverter.cs new file mode 100644 index 000000000..a487ddee7 --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamAclJsonConverter.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EventStore.Client { + internal class StreamAclJsonConverter : JsonConverter { + public static readonly StreamAclJsonConverter Instance = new StreamAclJsonConverter(); + + public override StreamAcl Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) { + string[]? read = null, + write = default, + delete = default, + metaRead = default, + metaWrite = default; + if (reader.TokenType != JsonTokenType.StartObject) { + throw new InvalidOperationException(); + } + + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.EndObject) { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new InvalidOperationException(); + } + + switch (reader.GetString()) { + case SystemMetadata.AclRead: + read = ReadRoles(ref reader); + break; + case SystemMetadata.AclWrite: + write = ReadRoles(ref reader); + break; + case SystemMetadata.AclDelete: + delete = ReadRoles(ref reader); + break; + case SystemMetadata.AclMetaRead: + metaRead = ReadRoles(ref reader); + break; + case SystemMetadata.AclMetaWrite: + metaWrite = ReadRoles(ref reader); + break; + } + } + + return new StreamAcl(read, write, delete, metaRead, metaWrite); + } + + private static string[]? ReadRoles(ref Utf8JsonReader reader) { + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + if (reader.TokenType == JsonTokenType.Null) { + return null; + } + + if (reader.TokenType == JsonTokenType.String) { + return new[] {reader.GetString()!}; + } + + if (reader.TokenType != JsonTokenType.StartArray) { + throw new InvalidOperationException(); + } + + var roles = new List(); + + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.EndArray) { + return roles.Count == 0 ? Array.Empty() : roles.ToArray(); + } + + if (reader.TokenType != JsonTokenType.String) { + throw new InvalidOperationException(); + } + + roles.Add(reader.GetString()!); + } + + return roles.ToArray(); + } + + public override void Write(Utf8JsonWriter writer, StreamAcl value, JsonSerializerOptions options) { + writer.WriteStartObject(); + + WriteRoles(writer, SystemMetadata.AclRead, value.ReadRoles); + WriteRoles(writer, SystemMetadata.AclWrite, value.WriteRoles); + WriteRoles(writer, SystemMetadata.AclDelete, value.DeleteRoles); + WriteRoles(writer, SystemMetadata.AclMetaRead, value.MetaReadRoles); + WriteRoles(writer, SystemMetadata.AclMetaWrite, value.MetaWriteRoles); + + writer.WriteEndObject(); + } + + private static void WriteRoles(Utf8JsonWriter writer, string name, string[]? roles) { + if (roles == null) { + return; + } + writer.WritePropertyName(name); + writer.WriteStartArray(); + foreach (var role in roles) { + writer.WriteStringValue(role); + } + + writer.WriteEndArray(); + } + } +} diff --git a/src/Kurrent.Client/Streams/StreamMessage.cs b/src/Kurrent.Client/Streams/StreamMessage.cs new file mode 100644 index 000000000..54aaffad9 --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamMessage.cs @@ -0,0 +1,83 @@ +namespace EventStore.Client { + /// + /// The base record of all stream messages. + /// + public abstract record StreamMessage { + /// + /// A that represents a . + /// + /// The . + public record Event(ResolvedEvent ResolvedEvent) : StreamMessage; + + /// + /// A representing a stream that was not found. + /// + public record NotFound : StreamMessage { + internal static readonly NotFound Instance = new(); + } + + /// + /// A representing a successful read operation. + /// + public record Ok : StreamMessage { + internal static readonly Ok Instance = new(); + }; + + /// + /// A indicating the first position of a stream. + /// + /// The . + public record FirstStreamPosition(StreamPosition StreamPosition) : StreamMessage; + + /// + /// A indicating the last position of a stream. + /// + /// The . + public record LastStreamPosition(StreamPosition StreamPosition) : StreamMessage; + + /// + /// A indicating the last position of the $all stream. + /// + /// The . + public record LastAllStreamPosition(Position Position) : StreamMessage; + + /// + /// A indicating that the subscription is ready to send additional messages. + /// + /// The unique identifier of the subscription. + public record SubscriptionConfirmation(string SubscriptionId) : StreamMessage; + + /// + /// A indicating that a checkpoint has been reached. + /// + /// The . + public record AllStreamCheckpointReached(Position Position) : StreamMessage; + + /// + /// A indicating that a checkpoint has been reached. + /// + /// The . + public record StreamCheckpointReached(StreamPosition StreamPosition) : StreamMessage; + + /// + /// A indicating that the subscription is live. + /// + public record CaughtUp : StreamMessage { + internal static readonly CaughtUp Instance = new(); + } + + /// + /// A indicating that the subscription has switched to catch up mode. + /// + public record FellBehind : StreamMessage { + internal static readonly FellBehind Instance = new(); + } + + /// + /// A that could not be identified, usually indicating a lower client compatibility level than the server supports. + /// + public record Unknown : StreamMessage { + internal static readonly Unknown Instance = new(); + } + } +} diff --git a/src/Kurrent.Client/Streams/StreamMetadata.cs b/src/Kurrent.Client/Streams/StreamMetadata.cs new file mode 100644 index 000000000..2b53dd977 --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamMetadata.cs @@ -0,0 +1,111 @@ +using System; +using System.Text.Json; + +namespace EventStore.Client { + /// + /// A structure representing a stream's custom metadata with strongly typed properties + /// for system values and a dictionary-like interface for custom values. + /// + public readonly struct StreamMetadata : IEquatable { + /// + /// The optional maximum age of events allowed in the stream. + /// + public TimeSpan? MaxAge { get; } + + /// + /// The optional from which previous events can be scavenged. + /// This is used to implement soft-deletion of streams. + /// + + public StreamPosition? TruncateBefore { get; } + + /// + /// The optional amount of time for which the stream head is cacheable. + /// + public TimeSpan? CacheControl { get; } + + /// + /// The optional for the stream. + /// + public StreamAcl? Acl { get; } + + /// + /// The optional maximum number of events allowed in the stream. + /// + public int? MaxCount { get; } + + /// + /// The optional of user provided metadata. + /// + public JsonDocument? CustomMetadata { get; } + + /// + /// Constructs a new . + /// + /// + /// + /// + /// + /// + /// + /// + public StreamMetadata( + int? maxCount = null, + TimeSpan? maxAge = null, + StreamPosition? truncateBefore = null, + TimeSpan? cacheControl = null, + StreamAcl? acl = null, + JsonDocument? customMetadata = null) : this() { + if (maxCount <= 0) { + throw new ArgumentOutOfRangeException(nameof(maxCount)); + } + + if (maxAge <= TimeSpan.Zero) { + throw new ArgumentOutOfRangeException(nameof(maxAge)); + } + + if (cacheControl <= TimeSpan.Zero) { + throw new ArgumentOutOfRangeException(nameof(cacheControl)); + } + + MaxAge = maxAge; + TruncateBefore = truncateBefore; + CacheControl = cacheControl; + Acl = acl; + MaxCount = maxCount; + CustomMetadata = customMetadata ?? JsonDocument.Parse("{}"); + } + + /// + public bool Equals(StreamMetadata other) => Nullable.Equals(MaxAge, other.MaxAge) && + Nullable.Equals(TruncateBefore, other.TruncateBefore) && + Nullable.Equals(CacheControl, other.CacheControl) && + Equals(Acl, other.Acl) && MaxCount == other.MaxCount && + string.Equals( + CustomMetadata?.RootElement.GetRawText(), + other.CustomMetadata?.RootElement.GetRawText()); + + /// + public override bool Equals(object? obj) => obj is StreamMetadata other && other.Equals(this); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(MaxAge).Combine(TruncateBefore).Combine(CacheControl) + .Combine(Acl?.GetHashCode()).Combine(MaxCount); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(StreamMetadata left, StreamMetadata right) => Equals(left, right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(StreamMetadata left, StreamMetadata right) => !Equals(left, right); + } +} diff --git a/src/Kurrent.Client/Streams/StreamMetadataJsonConverter.cs b/src/Kurrent.Client/Streams/StreamMetadataJsonConverter.cs new file mode 100644 index 000000000..68757a27b --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamMetadataJsonConverter.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EventStore.Client { + internal class StreamMetadataJsonConverter : JsonConverter { + public static readonly StreamMetadataJsonConverter Instance = new StreamMetadataJsonConverter(); + + public override StreamMetadata Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) { + int? maxCount = null; + TimeSpan? maxAge = null, cacheControl = null; + StreamPosition? truncateBefore = null; + StreamAcl? acl = null; + using var stream = new MemoryStream(); + using var customMetadataWriter = new Utf8JsonWriter(stream); + + if (reader.TokenType != JsonTokenType.StartObject) { + throw new InvalidOperationException(); + } + + customMetadataWriter.WriteStartObject(); + + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.EndObject) { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new InvalidOperationException(); + } + + switch (reader.GetString()) { + case SystemMetadata.MaxCount: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + maxCount = reader.GetInt32(); + break; + case SystemMetadata.MaxAge: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + var int64 = reader.GetInt64(); + maxAge = TimeSpan.FromSeconds(int64); + break; + case SystemMetadata.CacheControl: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + cacheControl = TimeSpan.FromSeconds(reader.GetInt64()); + break; + case SystemMetadata.TruncateBefore: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + var value = reader.GetInt64(); + truncateBefore = value == long.MaxValue + ? StreamPosition.End + : StreamPosition.FromInt64(value); + break; + case SystemMetadata.Acl: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + acl = StreamAclJsonConverter.Instance.Read(ref reader, typeof(StreamAcl), options); + break; + default: + customMetadataWriter.WritePropertyName(reader.GetString()!); + reader.Read(); + switch (reader.TokenType) { + case JsonTokenType.Comment: + customMetadataWriter.WriteCommentValue(reader.GetComment()); + break; + case JsonTokenType.String: + customMetadataWriter.WriteStringValue(reader.GetString()); + break; + case JsonTokenType.Number: + customMetadataWriter.WriteNumberValue(reader.GetDouble()); + break; + case JsonTokenType.True: + case JsonTokenType.False: + customMetadataWriter.WriteBooleanValue(reader.GetBoolean()); + break; + case JsonTokenType.Null: + reader.Read(); + customMetadataWriter.WriteNullValue(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + break; + } + } + + customMetadataWriter.WriteEndObject(); + customMetadataWriter.Flush(); + + stream.Position = 0; + + return new StreamMetadata(maxCount, maxAge, truncateBefore, cacheControl, acl, + JsonDocument.Parse(stream)); + } + + public override void Write(Utf8JsonWriter writer, StreamMetadata value, JsonSerializerOptions options) { + writer.WriteStartObject(); + + if (value.MaxCount.HasValue) { + writer.WriteNumber(SystemMetadata.MaxCount, value.MaxCount.Value); + } + + if (value.MaxAge.HasValue) { + writer.WriteNumber(SystemMetadata.MaxAge, (long)value.MaxAge.Value.TotalSeconds); + } + + if (value.TruncateBefore.HasValue) { + writer.WriteNumber(SystemMetadata.TruncateBefore, value.TruncateBefore.Value.ToInt64()); + } + + if (value.CacheControl.HasValue) { + writer.WriteNumber(SystemMetadata.CacheControl, (long)value.CacheControl.Value.TotalSeconds); + } + + if (value.Acl != null) { + writer.WritePropertyName(SystemMetadata.Acl); + StreamAclJsonConverter.Instance.Write(writer, value.Acl, options); + } + + if (value.CustomMetadata != null) { + foreach (var property in value.CustomMetadata.RootElement.EnumerateObject()) { + property.WriteTo(writer); + } + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Kurrent.Client/Streams/StreamMetadataResult.cs b/src/Kurrent.Client/Streams/StreamMetadataResult.cs new file mode 100644 index 000000000..080ba15ef --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamMetadataResult.cs @@ -0,0 +1,83 @@ +using System; + +namespace EventStore.Client { + /// + /// Represents stream metadata as a series of properties for system + /// data (e.g., MaxAge) and a object for user metadata. + /// + public struct StreamMetadataResult : IEquatable { + /// + /// The name of the stream. + /// + public readonly string StreamName; + + /// + /// True if the stream is deleted. + /// + public readonly bool StreamDeleted; + + /// + /// A containing user-specified metadata. + /// + public readonly StreamMetadata Metadata; + + /// + /// A of the version of the metadata. + /// + public readonly StreamPosition? MetastreamRevision; + + /// + public override int GetHashCode() => + HashCode.Hash.Combine(StreamName).Combine(Metadata).Combine(MetastreamRevision); + + /// + public bool Equals(StreamMetadataResult other) => + StreamName == other.StreamName && StreamDeleted == other.StreamDeleted && + Equals(Metadata, other.Metadata) && Nullable.Equals(MetastreamRevision, other.MetastreamRevision); + + /// + public override bool Equals(object? obj) => obj is StreamMetadataResult other && Equals(other); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(StreamMetadataResult left, StreamMetadataResult right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(StreamMetadataResult left, StreamMetadataResult right) => !left.Equals(right); + + /// + /// A representing no metadata. + /// + /// + /// + public static StreamMetadataResult None(string streamName) => new StreamMetadataResult(streamName); + + /// + /// A factory method to create a new . + /// + /// + /// + /// + /// + /// + public static StreamMetadataResult Create(string streamName, StreamPosition revision, + StreamMetadata metadata) => new StreamMetadataResult(streamName, revision, metadata); + + private StreamMetadataResult(string streamName, StreamPosition? metastreamRevision = null, + StreamMetadata metadata = default, bool streamDeleted = false) { + StreamName = streamName; + StreamDeleted = streamDeleted; + Metadata = metadata; + MetastreamRevision = metastreamRevision; + } + } +} diff --git a/src/Kurrent.Client/Streams/StreamSubscription.cs b/src/Kurrent.Client/Streams/StreamSubscription.cs new file mode 100644 index 000000000..e7271b364 --- /dev/null +++ b/src/Kurrent.Client/Streams/StreamSubscription.cs @@ -0,0 +1,165 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace EventStore.Client { + /// + /// A class representing a . + /// + public class StreamSubscription : IDisposable { + private readonly KurrentClient.StreamSubscriptionResult _subscription; + private readonly IAsyncEnumerator _messages; + private readonly Func _eventAppeared; + private readonly Func _checkpointReached; + private readonly Action? _subscriptionDropped; + private readonly ILogger _log; + private readonly CancellationTokenSource _cts; + private int _subscriptionDroppedInvoked; + + /// + /// The id of the set by the server. + /// + public string SubscriptionId { get; } + + internal static async Task Confirm( + KurrentClient.StreamSubscriptionResult subscription, + Func eventAppeared, + Action? subscriptionDropped, + ILogger log, + Func? checkpointReached = null, + CancellationToken cancellationToken = default + ) { + var messages = subscription.Messages; + + var enumerator = messages.GetAsyncEnumerator(cancellationToken); + if (!await enumerator.MoveNextAsync().ConfigureAwait(false) || + enumerator.Current is not StreamMessage.SubscriptionConfirmation(var subscriptionId)) { + throw new InvalidOperationException($"Subscription to {enumerator} could not be confirmed."); + } + + return new StreamSubscription( + subscription, + enumerator, + subscriptionId, + eventAppeared, + subscriptionDropped, + log, + checkpointReached, + cancellationToken + ); + } + + private StreamSubscription( + KurrentClient.StreamSubscriptionResult subscription, + IAsyncEnumerator messages, string subscriptionId, + Func eventAppeared, + Action? subscriptionDropped, + ILogger log, + Func? checkpointReached, + CancellationToken cancellationToken = default + ) { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _subscription = subscription; + _messages = messages; + _eventAppeared = eventAppeared; + _checkpointReached = checkpointReached ?? ((_, _, _) => Task.CompletedTask); + _subscriptionDropped = subscriptionDropped; + _log = log; + _subscriptionDroppedInvoked = 0; + SubscriptionId = subscriptionId; + + _log.LogDebug("Subscription {subscriptionId} confirmed.", SubscriptionId); + + Task.Run(Subscribe, cancellationToken); + } + + private async Task Subscribe() { + using var _ = _cts; + + try { + while (await _messages.MoveNextAsync().ConfigureAwait(false)) { + var message = _messages.Current; + try { + switch (message) { + case StreamMessage.Event(var resolvedEvent): + _log.LogTrace( + "Subscription {subscriptionId} received event {streamName}@{streamRevision} {position}", + SubscriptionId, + resolvedEvent.OriginalEvent.EventStreamId, + resolvedEvent.OriginalEvent.EventNumber, + resolvedEvent.OriginalEvent.Position + ); + + await _eventAppeared(this, resolvedEvent, _cts.Token).ConfigureAwait(false); + break; + + case StreamMessage.AllStreamCheckpointReached (var position): + await _checkpointReached(this, position, _cts.Token) + .ConfigureAwait(false); + + break; + } + } catch (Exception ex) when + (ex is ObjectDisposedException or OperationCanceledException) { + if (_subscriptionDroppedInvoked != 0) { + return; + } + + _log.LogWarning( + ex, + "Subscription {subscriptionId} was dropped because cancellation was requested by another caller.", + SubscriptionId + ); + + SubscriptionDropped(SubscriptionDroppedReason.Disposed); + + return; + } catch (Exception ex) { + _log.LogError( + ex, + "Subscription {subscriptionId} was dropped because the subscriber made an error.", + SubscriptionId + ); + + SubscriptionDropped(SubscriptionDroppedReason.SubscriberError, ex); + + return; + } + } + } catch (RpcException ex) when (ex.Status.StatusCode == StatusCode.Cancelled && + ex.Status.Detail.Contains("Call canceled by the client.")) { + _log.LogInformation( + "Subscription {subscriptionId} was dropped because cancellation was requested by the client.", + SubscriptionId + ); + + SubscriptionDropped(SubscriptionDroppedReason.Disposed, ex); + } catch (Exception ex) { + if (_subscriptionDroppedInvoked == 0) { + _log.LogError( + ex, + "Subscription {subscriptionId} was dropped because an error occurred on the server.", + SubscriptionId + ); + + SubscriptionDropped(SubscriptionDroppedReason.ServerError, ex); + } + } + } + + /// + public void Dispose() => SubscriptionDropped(SubscriptionDroppedReason.Disposed); + + private void SubscriptionDropped(SubscriptionDroppedReason reason, Exception? ex = null) { + if (Interlocked.CompareExchange(ref _subscriptionDroppedInvoked, 1, 0) == 1) { + return; + } + + try { + _subscriptionDropped?.Invoke(this, reason, ex); + } finally { + _subscription.Dispose(); + _cts.Dispose(); + } + } + } +} diff --git a/src/Kurrent.Client/Streams/Streams/AppendReq.cs b/src/Kurrent.Client/Streams/Streams/AppendReq.cs new file mode 100644 index 000000000..f32611530 --- /dev/null +++ b/src/Kurrent.Client/Streams/Streams/AppendReq.cs @@ -0,0 +1,15 @@ +namespace EventStore.Client.Streams { + partial class AppendReq { + public AppendReq WithAnyStreamRevision(StreamState expectedState) { + if (expectedState == StreamState.Any) { + Options.Any = new Empty(); + } else if (expectedState == StreamState.NoStream) { + Options.NoStream = new Empty(); + } else if (expectedState == StreamState.StreamExists) { + Options.StreamExists = new Empty(); + } + + return this; + } + } +} diff --git a/src/Kurrent.Client/Streams/Streams/BatchAppendReq.cs b/src/Kurrent.Client/Streams/Streams/BatchAppendReq.cs new file mode 100644 index 000000000..f9e1146c0 --- /dev/null +++ b/src/Kurrent.Client/Streams/Streams/BatchAppendReq.cs @@ -0,0 +1,34 @@ +using System; +using Google.Protobuf.WellKnownTypes; + +namespace EventStore.Client.Streams { + partial class BatchAppendReq { + partial class Types { + partial class Options { + public static Options Create(StreamIdentifier streamIdentifier, + StreamRevision expectedStreamRevision, TimeSpan? timeoutAfter) => new() { + StreamIdentifier = streamIdentifier, + StreamPosition = expectedStreamRevision.ToUInt64(), + Deadline21100 = Timestamp.FromDateTime(timeoutAfter.HasValue + ? DateTime.UtcNow + timeoutAfter.Value + : DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc)) + }; + public static Options Create(StreamIdentifier streamIdentifier, StreamState expectedState, + TimeSpan? timeoutAfter) => new() { + StreamIdentifier = streamIdentifier, + expectedStreamPositionCase_ = expectedState switch { + { } when expectedState == StreamState.Any => ExpectedStreamPositionOneofCase.Any, + { } when expectedState == StreamState.NoStream => ExpectedStreamPositionOneofCase.NoStream, + { } when expectedState == StreamState.StreamExists => ExpectedStreamPositionOneofCase + .StreamExists, + _ => ExpectedStreamPositionOneofCase.None + }, + expectedStreamPosition_ = new Google.Protobuf.WellKnownTypes.Empty(), + Deadline21100 = Timestamp.FromDateTime(timeoutAfter.HasValue + ? DateTime.UtcNow + timeoutAfter.Value + : DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc)) + }; + } + } + } +} diff --git a/src/Kurrent.Client/Streams/Streams/BatchAppendResp.cs b/src/Kurrent.Client/Streams/Streams/BatchAppendResp.cs new file mode 100644 index 000000000..4be031057 --- /dev/null +++ b/src/Kurrent.Client/Streams/Streams/BatchAppendResp.cs @@ -0,0 +1,49 @@ +using Grpc.Core; +using EventStore.Client; +using static EventStore.Client.WrongExpectedVersion.CurrentStreamRevisionOptionOneofCase; +using static EventStore.Client.WrongExpectedVersion.ExpectedStreamPositionOptionOneofCase; + +namespace EventStore.Client.Streams { + partial class BatchAppendResp { + public IWriteResult ToWriteResult() => ResultCase switch { + ResultOneofCase.Success => new SuccessResult( + Success.CurrentRevisionOptionCase switch { + Types.Success.CurrentRevisionOptionOneofCase.CurrentRevision => + new StreamRevision(Success.CurrentRevision), + _ => StreamRevision.None + }, Success.PositionOptionCase switch { + Types.Success.PositionOptionOneofCase.Position => new Position( + Success.Position.CommitPosition, + Success.Position.PreparePosition), + _ => Position.End + }), + ResultOneofCase.Error => Error.Details switch { + { } when Error.Details.Is(WrongExpectedVersion.Descriptor) => + FromWrongExpectedVersion(StreamIdentifier, Error.Details.Unpack()), + { } when Error.Details.Is(StreamDeleted.Descriptor) => + throw new StreamDeletedException(StreamIdentifier!), + { } when Error.Details.Is(AccessDenied.Descriptor) => throw new AccessDeniedException(), + { } when Error.Details.Is(Timeout.Descriptor) => throw new RpcException( + new Status(StatusCode.DeadlineExceeded, Error.Message)), + { } when Error.Details.Is(Unknown.Descriptor) => throw new InvalidOperationException(Error.Message), + { } when Error.Details.Is(MaximumAppendSizeExceeded.Descriptor) => + throw new MaximumAppendSizeExceededException( + Error.Details.Unpack().MaxAppendSize), + { } when Error.Details.Is(BadRequest.Descriptor) => throw new InvalidOperationException(Error.Details + .Unpack().Message), + _ => throw new InvalidOperationException($"Could not recognize {Error.Message}") + }, + _ => throw new InvalidOperationException() + }; + + private static WrongExpectedVersionResult FromWrongExpectedVersion(StreamIdentifier streamIdentifier, + WrongExpectedVersion wrongExpectedVersion) => new(streamIdentifier!, + wrongExpectedVersion.ExpectedStreamPositionOptionCase switch { + ExpectedStreamPosition => wrongExpectedVersion.ExpectedStreamPosition, + _ => StreamRevision.None + }, wrongExpectedVersion.CurrentStreamRevisionOptionCase switch { + CurrentStreamRevision => wrongExpectedVersion.CurrentStreamRevision, + _ => StreamRevision.None + }); + } +} diff --git a/src/Kurrent.Client/Streams/Streams/DeleteReq.cs b/src/Kurrent.Client/Streams/Streams/DeleteReq.cs new file mode 100644 index 000000000..94600138c --- /dev/null +++ b/src/Kurrent.Client/Streams/Streams/DeleteReq.cs @@ -0,0 +1,15 @@ +namespace EventStore.Client.Streams { + partial class DeleteReq { + public DeleteReq WithAnyStreamRevision(StreamState expectedState) { + if (expectedState == StreamState.Any) { + Options.Any = new Empty(); + } else if (expectedState == StreamState.NoStream) { + Options.NoStream = new Empty(); + } else if (expectedState == StreamState.StreamExists) { + Options.StreamExists = new Empty(); + } + + return this; + } + } +} diff --git a/src/Kurrent.Client/Streams/Streams/ReadReq.cs b/src/Kurrent.Client/Streams/Streams/ReadReq.cs new file mode 100644 index 000000000..ed5ca3af7 --- /dev/null +++ b/src/Kurrent.Client/Streams/Streams/ReadReq.cs @@ -0,0 +1,86 @@ +using System; + +namespace EventStore.Client.Streams { + partial class ReadReq { + partial class Types { + partial class Options { + partial class Types { + partial class StreamOptions { + public static StreamOptions FromSubscriptionPosition(string streamName, + FromStream fromStream) { + if (fromStream == FromStream.End) { + return new StreamOptions { + StreamIdentifier = streamName, + End = new Empty() + }; + } + + if (fromStream == FromStream.Start) { + return new StreamOptions { + StreamIdentifier = streamName, + Start = new Empty() + }; + } + + return new StreamOptions { + StreamIdentifier = streamName, + Revision = fromStream.ToUInt64() + }; + } + public static StreamOptions FromStreamNameAndRevision( + string streamName, + StreamPosition streamRevision) { + if (streamName == null) { + throw new ArgumentNullException(nameof(streamName)); + } + + if (streamRevision == StreamPosition.End) { + return new StreamOptions { + StreamIdentifier = streamName, + End = new Empty() + }; + } + + if (streamRevision == StreamPosition.Start) { + return new StreamOptions { + StreamIdentifier = streamName, + Start = new Empty() + }; + } + + return new StreamOptions { + StreamIdentifier = streamName, + Revision = streamRevision + }; + } + } + + partial class AllOptions { + public static AllOptions FromSubscriptionPosition(FromAll position) { + if (position == FromAll.End) { + return new AllOptions { + End = new Empty() + }; + } + + if (position == FromAll.Start) { + return new AllOptions { + Start = new Empty() + }; + } + + var (c, p) = position.ToUInt64(); + + return new AllOptions { + Position = new Position { + CommitPosition = c, + PreparePosition = p + } + }; + } + } + } + } + } + } +} diff --git a/src/Kurrent.Client/Streams/Streams/TombstoneReq.cs b/src/Kurrent.Client/Streams/Streams/TombstoneReq.cs new file mode 100644 index 000000000..cbe1ef0a0 --- /dev/null +++ b/src/Kurrent.Client/Streams/Streams/TombstoneReq.cs @@ -0,0 +1,15 @@ +namespace EventStore.Client.Streams { + partial class TombstoneReq { + public TombstoneReq WithAnyStreamRevision(StreamState expectedState) { + if (expectedState == StreamState.Any) { + Options.Any = new Empty(); + } else if (expectedState == StreamState.NoStream) { + Options.NoStream = new Empty(); + } else if (expectedState == StreamState.StreamExists) { + Options.StreamExists = new Empty(); + } + + return this; + } + } +} diff --git a/src/Kurrent.Client/Streams/SubscriptionFilterOptions.cs b/src/Kurrent.Client/Streams/SubscriptionFilterOptions.cs new file mode 100644 index 000000000..b6eb22dd0 --- /dev/null +++ b/src/Kurrent.Client/Streams/SubscriptionFilterOptions.cs @@ -0,0 +1,54 @@ +namespace EventStore.Client { + /// + /// A class representing the options to use when filtering read operations. + /// + public class SubscriptionFilterOptions { + /// + /// The to apply. + /// + public IEventFilter Filter { get; } + + /// + /// Sets how often the checkpointReached callback is called. + /// + public uint CheckpointInterval { get; } + + /// + /// A Task invoked and await when a checkpoint is reached. + /// Set the checkpointInterval to define how often this method is called. + /// + public Func CheckpointReached { get; } = null!; + + /// + /// + /// + /// The to apply. + /// Sets how often the checkpointReached callback is called. + /// + /// A Task invoked and await when a checkpoint is reached. + /// Set the checkpointInterval to define how often this method is called. + /// + /// + public SubscriptionFilterOptions(IEventFilter filter, uint checkpointInterval, + Func? checkpointReached) + : this(filter, checkpointInterval) { + CheckpointReached = checkpointReached ?? ((_, __, ct) => Task.CompletedTask); + } + + /// + /// + /// + /// The to apply. + /// Sets how often the checkpointReached callback is called. + /// + public SubscriptionFilterOptions(IEventFilter filter, uint checkpointInterval = 1) { + if (checkpointInterval == 0) { + throw new ArgumentOutOfRangeException(nameof(checkpointInterval), + checkpointInterval, $"{nameof(checkpointInterval)} must be greater than 0."); + } + + Filter = filter; + CheckpointInterval = checkpointInterval; + } + } +} diff --git a/src/Kurrent.Client/Streams/SuccessResult.cs b/src/Kurrent.Client/Streams/SuccessResult.cs new file mode 100644 index 000000000..0624d6fcc --- /dev/null +++ b/src/Kurrent.Client/Streams/SuccessResult.cs @@ -0,0 +1,57 @@ +using System; + +namespace EventStore.Client { + /// + /// An that indicates a successful append to a stream. + /// + public readonly struct SuccessResult : IWriteResult, IEquatable { + /// + public long NextExpectedVersion { get; } + + /// + public Position LogPosition { get; } + + /// + public StreamRevision NextExpectedStreamRevision { get; } + + /// + /// Constructs a new . + /// + /// + /// + public SuccessResult(StreamRevision nextExpectedStreamRevision, Position logPosition) { + NextExpectedStreamRevision = nextExpectedStreamRevision; + LogPosition = logPosition; + NextExpectedVersion = nextExpectedStreamRevision.ToInt64(); + } + + /// + public bool Equals(SuccessResult other) => + NextExpectedStreamRevision == other.NextExpectedStreamRevision && LogPosition.Equals(other.LogPosition); + + /// + public override bool Equals(object? obj) => obj is SuccessResult other && Equals(other); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(SuccessResult left, SuccessResult right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is equal not to right. + public static bool operator !=(SuccessResult left, SuccessResult right) => !left.Equals(right); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(NextExpectedVersion).Combine(LogPosition); + + /// + public override string ToString() => $"{NextExpectedStreamRevision}:{LogPosition}"; + } +} diff --git a/src/Kurrent.Client/Streams/SystemEventTypes.cs b/src/Kurrent.Client/Streams/SystemEventTypes.cs new file mode 100644 index 000000000..48e585160 --- /dev/null +++ b/src/Kurrent.Client/Streams/SystemEventTypes.cs @@ -0,0 +1,31 @@ +namespace EventStore.Client { + /// + ///Constants for System event types + /// + public static class SystemEventTypes { + /// + /// event type for stream deleted + /// + public const string StreamDeleted = "$streamDeleted"; + + /// + /// event type for statistics + /// + public const string StatsCollection = "$statsCollected"; + + /// + /// event type for linkTo + /// + public const string LinkTo = "$>"; + + /// + /// event type for stream metadata + /// + public const string StreamMetadata = "$metadata"; + + /// + /// event type for the system settings + /// + public const string Settings = "$settings"; + } +} diff --git a/src/Kurrent.Client/Streams/SystemMetadata.cs b/src/Kurrent.Client/Streams/SystemMetadata.cs new file mode 100644 index 000000000..7cce81c90 --- /dev/null +++ b/src/Kurrent.Client/Streams/SystemMetadata.cs @@ -0,0 +1,71 @@ +namespace EventStore.Client { + /// + ///Constants for information in stream metadata + /// + internal static class SystemMetadata { + /// + ///The definition of the MaxAge value assigned to stream metadata + ///Setting this allows all events older than the limit to be deleted + /// + public const string MaxAge = "$maxAge"; + + /// + ///The definition of the MaxCount value assigned to stream metadata + ///setting this allows all events with a sequence less than current -maxcount to be deleted + /// + public const string MaxCount = "$maxCount"; + + /// + ///The definition of the Truncate Before value assigned to stream metadata + ///setting this allows all events prior to the integer value to be deleted + /// + public const string TruncateBefore = "$tb"; + + /// + /// Sets the cache control in seconds for the head of the stream. + /// + public const string CacheControl = "$cacheControl"; + + + /// + /// The acl definition in metadata + /// + public const string Acl = "$acl"; + + /// + /// to read from a stream + /// + public const string AclRead = "$r"; + + /// + /// to write to a stream + /// + public const string AclWrite = "$w"; + + /// + /// to delete a stream + /// + public const string AclDelete = "$d"; + + /// + /// to read metadata + /// + public const string AclMetaRead = "$mr"; + + /// + /// to write metadata + /// + public const string AclMetaWrite = "$mw"; + + + /// + /// The user default acl stream + /// + public const string UserStreamAcl = "$userStreamAcl"; + + /// + /// the system stream defaults acl stream + /// + public const string SystemStreamAcl = "$systemStreamAcl"; + } +} diff --git a/src/Kurrent.Client/Streams/SystemSettings.cs b/src/Kurrent.Client/Streams/SystemSettings.cs new file mode 100644 index 000000000..37ccd661e --- /dev/null +++ b/src/Kurrent.Client/Streams/SystemSettings.cs @@ -0,0 +1,53 @@ +namespace EventStore.Client { + /// + /// A class representing default access control lists. + /// + public sealed class SystemSettings { + /// + /// Default access control list for new user streams. + /// + public StreamAcl? UserStreamAcl { get; } + + /// + /// Default access control list for new system streams. + /// + public StreamAcl? SystemStreamAcl { get; } + + /// + /// Constructs a new . + /// + /// + /// + public SystemSettings(StreamAcl? userStreamAcl = null, StreamAcl? systemStreamAcl = null) { + UserStreamAcl = userStreamAcl; + SystemStreamAcl = systemStreamAcl; + } + + private bool Equals(SystemSettings other) + => Equals(UserStreamAcl, other.UserStreamAcl) && Equals(SystemStreamAcl, other.SystemStreamAcl); + + /// + public override bool Equals(object? obj) + => ReferenceEquals(this, obj) || obj is SystemSettings other && Equals(other); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(SystemSettings? left, SystemSettings? right) => Equals(left, right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(SystemSettings? left, SystemSettings? right) => !Equals(left, right); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(UserStreamAcl?.GetHashCode()) + .Combine(SystemStreamAcl?.GetHashCode()); + } +} diff --git a/src/Kurrent.Client/Streams/SystemSettingsJsonConverter.cs b/src/Kurrent.Client/Streams/SystemSettingsJsonConverter.cs new file mode 100644 index 000000000..03d7e7b9c --- /dev/null +++ b/src/Kurrent.Client/Streams/SystemSettingsJsonConverter.cs @@ -0,0 +1,62 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EventStore.Client { + internal class SystemSettingsJsonConverter : JsonConverter { + public static readonly SystemSettingsJsonConverter Instance = new SystemSettingsJsonConverter(); + + public override SystemSettings Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.StartObject) { + throw new InvalidOperationException(); + } + + StreamAcl? system = null, user = null; + + while (reader.Read()) { + if (reader.TokenType == JsonTokenType.EndObject) { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new InvalidOperationException(); + } + + switch (reader.GetString()) { + case SystemMetadata.SystemStreamAcl: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + system = StreamAclJsonConverter.Instance.Read(ref reader, typeof(StreamAcl), options); + break; + case SystemMetadata.UserStreamAcl: + if (!reader.Read()) { + throw new InvalidOperationException(); + } + + user = StreamAclJsonConverter.Instance.Read(ref reader, typeof(StreamAcl), options); + break; + } + } + + return new SystemSettings(user, system); + } + + public override void Write(Utf8JsonWriter writer, SystemSettings value, JsonSerializerOptions options) { + writer.WriteStartObject(); + if (value.UserStreamAcl != null) { + writer.WritePropertyName(SystemMetadata.UserStreamAcl); + StreamAclJsonConverter.Instance.Write(writer, value.UserStreamAcl, options); + } + + if (value.SystemStreamAcl != null) { + writer.WritePropertyName(SystemMetadata.SystemStreamAcl); + StreamAclJsonConverter.Instance.Write(writer, value.SystemStreamAcl, options); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Kurrent.Client/Streams/WriteResultExtensions.cs b/src/Kurrent.Client/Streams/WriteResultExtensions.cs new file mode 100644 index 000000000..7b5005e02 --- /dev/null +++ b/src/Kurrent.Client/Streams/WriteResultExtensions.cs @@ -0,0 +1,12 @@ +namespace EventStore.Client { + internal static class WriteResultExtensions { + public static IWriteResult OptionallyThrowWrongExpectedVersionException(this IWriteResult writeResult, + KurrentClientOperationOptions options) => + (options.ThrowOnAppendFailure, writeResult) switch { + (true, WrongExpectedVersionResult wrongExpectedVersionResult) + => throw new WrongExpectedVersionException(wrongExpectedVersionResult.StreamName, + writeResult.NextExpectedStreamRevision, wrongExpectedVersionResult.ActualStreamRevision), + _ => writeResult + }; + } +} diff --git a/src/Kurrent.Client/Streams/WrongExpectedVersionResult.cs b/src/Kurrent.Client/Streams/WrongExpectedVersionResult.cs new file mode 100644 index 000000000..7dd4887ca --- /dev/null +++ b/src/Kurrent.Client/Streams/WrongExpectedVersionResult.cs @@ -0,0 +1,58 @@ +namespace EventStore.Client { + /// + /// An that indicates a failed append to a stream. + /// + public readonly struct WrongExpectedVersionResult : IWriteResult { + /// + /// The name of the stream. + /// + public string StreamName { get; } + + /// + public long NextExpectedVersion { get; } + + /// + /// The version the stream is at. + /// + public long ActualVersion { get; } + + /// + /// The the stream is at. + /// + public StreamRevision ActualStreamRevision { get; } + + /// + public Position LogPosition { get; } + + /// + public StreamRevision NextExpectedStreamRevision { get; } + + /// + /// Construct a new . + /// + /// + /// + public WrongExpectedVersionResult(string streamName, StreamRevision nextExpectedStreamRevision) { + StreamName = streamName; + ActualVersion = NextExpectedVersion = nextExpectedStreamRevision.ToInt64(); + ActualStreamRevision = NextExpectedStreamRevision = nextExpectedStreamRevision; + LogPosition = default; + } + + /// + /// Construct a new . + /// + /// + /// + /// + public WrongExpectedVersionResult(string streamName, StreamRevision nextExpectedStreamRevision, + StreamRevision actualStreamRevision) { + StreamName = streamName; + ActualVersion = actualStreamRevision.ToInt64(); + ActualStreamRevision = actualStreamRevision; + NextExpectedVersion = nextExpectedStreamRevision.ToInt64(); + NextExpectedStreamRevision = nextExpectedStreamRevision; + LogPosition = default; + } + } +} diff --git a/src/Kurrent.Client/UserManagement/KurrentUserManagementClient.cs b/src/Kurrent.Client/UserManagement/KurrentUserManagementClient.cs new file mode 100644 index 000000000..ce18d40a0 --- /dev/null +++ b/src/Kurrent.Client/UserManagement/KurrentUserManagementClient.cs @@ -0,0 +1,279 @@ +using System.Runtime.CompilerServices; +using EventStore.Client.Users; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EventStore.Client { + /// + /// The client used for operations on internal users. + /// + public sealed class KurrentUserManagementClient : KurrentClientBase { + private readonly ILogger _log; + + /// + /// Constructs a new . + /// + /// + public KurrentUserManagementClient(KurrentClientSettings? settings = null) : + base(settings, ExceptionMap) { + _log = Settings.LoggerFactory?.CreateLogger() ?? + new NullLogger(); + } + + /// + /// Creates an internal user. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public async Task CreateUserAsync(string loginName, string fullName, string[] groups, string password, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (loginName == null) throw new ArgumentNullException(nameof(loginName)); + if (fullName == null) throw new ArgumentNullException(nameof(fullName)); + if (groups == null) throw new ArgumentNullException(nameof(groups)); + if (password == null) throw new ArgumentNullException(nameof(password)); + if (loginName == string.Empty) throw new ArgumentOutOfRangeException(nameof(loginName)); + if (fullName == string.Empty) throw new ArgumentOutOfRangeException(nameof(fullName)); + if (password == string.Empty) throw new ArgumentOutOfRangeException(nameof(password)); + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).CreateAsync(new CreateReq { + Options = new CreateReq.Types.Options { + LoginName = loginName, + FullName = fullName, + Password = password, + Groups = {groups} + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Gets the of an internal user. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task GetUserAsync(string loginName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + if (loginName == null) { + throw new ArgumentNullException(nameof(loginName)); + } + + if (loginName == string.Empty) { + throw new ArgumentOutOfRangeException(nameof(loginName)); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).Details(new DetailsReq { + Options = new DetailsReq.Types.Options { + LoginName = loginName + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + await call.ResponseStream.MoveNext().ConfigureAwait(false); + var userDetails = call.ResponseStream.Current.UserDetails; + return ConvertUserDetails(userDetails); + } + + private static UserDetails ConvertUserDetails(DetailsResp.Types.UserDetails userDetails) => + new UserDetails(userDetails.LoginName, userDetails.FullName, userDetails.Groups.ToArray(), + userDetails.Disabled, userDetails.LastUpdated?.TicksSinceEpoch.FromTicksSinceEpoch()); + + /// + /// Deletes an internal user. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task DeleteUserAsync(string loginName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + if (loginName == null) { + throw new ArgumentNullException(nameof(loginName)); + } + + if (loginName == string.Empty) { + throw new ArgumentOutOfRangeException(nameof(loginName)); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).DeleteAsync(new DeleteReq { + Options = new DeleteReq.Types.Options { + LoginName = loginName + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Enables a previously disabled internal user. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task EnableUserAsync(string loginName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + if (loginName == null) { + throw new ArgumentNullException(nameof(loginName)); + } + + if (loginName == string.Empty) { + throw new ArgumentOutOfRangeException(nameof(loginName)); + } + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).EnableAsync(new EnableReq { + Options = new EnableReq.Types.Options { + LoginName = loginName + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Disables an internal user. + /// + /// + /// + /// + /// + /// + /// + public async Task DisableUserAsync(string loginName, TimeSpan? deadline = null, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { + if (loginName == string.Empty) throw new ArgumentOutOfRangeException(nameof(loginName)); + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).DisableAsync(new DisableReq { + Options = new DisableReq.Types.Options { + LoginName = loginName + } + }, KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Lists the of all internal users. + /// + /// + /// + /// + /// + public async IAsyncEnumerable ListAllAsync(TimeSpan? deadline = null, + UserCredentials? userCredentials = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).Details(new DetailsReq(), + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + + await foreach (var userDetail in call.ResponseStream + .ReadAllAsync(cancellationToken) + .Select(x => ConvertUserDetails(x.UserDetails)) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) { + yield return userDetail; + } + } + + /// + /// Changes the password of an internal user. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public async Task ChangePasswordAsync(string loginName, string currentPassword, string newPassword, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (loginName == null) throw new ArgumentNullException(nameof(loginName)); + if (currentPassword == null) throw new ArgumentNullException(nameof(currentPassword)); + if (newPassword == null) throw new ArgumentNullException(nameof(newPassword)); + if (loginName == string.Empty) throw new ArgumentOutOfRangeException(nameof(loginName)); + if (currentPassword == string.Empty) throw new ArgumentOutOfRangeException(nameof(currentPassword)); + if (newPassword == string.Empty) throw new ArgumentOutOfRangeException(nameof(newPassword)); + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + using var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).ChangePasswordAsync( + new ChangePasswordReq { + Options = new ChangePasswordReq.Types.Options { + CurrentPassword = currentPassword, + NewPassword = newPassword, + LoginName = loginName + } + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + /// + /// Resets the password of an internal user. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public async Task ResetPasswordAsync(string loginName, string newPassword, + TimeSpan? deadline = null, UserCredentials? userCredentials = null, + CancellationToken cancellationToken = default) { + if (loginName == null) throw new ArgumentNullException(nameof(loginName)); + if (newPassword == null) throw new ArgumentNullException(nameof(newPassword)); + if (loginName == string.Empty) throw new ArgumentOutOfRangeException(nameof(loginName)); + if (newPassword == string.Empty) throw new ArgumentOutOfRangeException(nameof(newPassword)); + + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + var call = new Users.Users.UsersClient( + channelInfo.CallInvoker).ResetPasswordAsync( + new ResetPasswordReq { + Options = new ResetPasswordReq.Types.Options { + NewPassword = newPassword, + LoginName = loginName + } + }, + KurrentCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); + await call.ResponseAsync.ConfigureAwait(false); + } + + private static readonly Dictionary> ExceptionMap = + new Dictionary> { + [Constants.Exceptions.UserNotFound] = ex => new UserNotFoundException( + ex.Trailers.First(x => x.Key == Constants.Exceptions.LoginName).Value), + }; + } +} diff --git a/src/Kurrent.Client/UserManagement/KurrentUserManagementClientCollectionExtensions.cs b/src/Kurrent.Client/UserManagement/KurrentUserManagementClientCollectionExtensions.cs new file mode 100644 index 000000000..220f99740 --- /dev/null +++ b/src/Kurrent.Client/UserManagement/KurrentUserManagementClientCollectionExtensions.cs @@ -0,0 +1,72 @@ +// ReSharper disable CheckNamespace + +using System.Net.Http; +using EventStore.Client; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.DependencyInjection { + /// + /// A set of extension methods for which provide support for an . + /// + public static class KurrentUserManagementClientCollectionExtensions { + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentUserManagementClient(this IServiceCollection services, + Uri address, Func? createHttpMessageHandler = null) + => services.AddKurrentUserManagementClient(options => { + options.ConnectivitySettings.Address = address; + options.CreateHttpMessageHandler = createHttpMessageHandler; + }); + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentUserManagementClient(this IServiceCollection services, + string connectionString, Action? configureSettings = null) + => services.AddKurrentUserManagementClient(KurrentClientSettings.Create(connectionString), + configureSettings); + + + /// + /// Adds an to the . + /// + /// + /// + /// + /// + public static IServiceCollection AddKurrentUserManagementClient(this IServiceCollection services, + Action? configureSettings = null) => + services.AddKurrentUserManagementClient(new KurrentClientSettings(), configureSettings); + + private static IServiceCollection AddKurrentUserManagementClient(this IServiceCollection services, + KurrentClientSettings settings, Action? configureSettings = null) { + configureSettings?.Invoke(settings); + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + + services.TryAddSingleton(provider => { + settings.LoggerFactory ??= provider.GetService(); + settings.Interceptors ??= provider.GetServices(); + + return new KurrentUserManagementClient(settings); + }); + + return services; + } + } +} +// ReSharper restore CheckNamespace diff --git a/src/Kurrent.Client/UserManagement/KurrentUserManagerClientExtensions.cs b/src/Kurrent.Client/UserManagement/KurrentUserManagerClientExtensions.cs new file mode 100644 index 000000000..be365fdd2 --- /dev/null +++ b/src/Kurrent.Client/UserManagement/KurrentUserManagerClientExtensions.cs @@ -0,0 +1,23 @@ +namespace EventStore.Client; + +/// +/// A set of extension methods for an . +/// +public static class KurrentUserManagerClientExtensions { + /// + /// Gets the of the internal user specified by the supplied . + /// + /// + /// + /// + /// + /// + public static Task GetCurrentUserAsync( + this KurrentUserManagementClient users, + UserCredentials userCredentials, TimeSpan? deadline = null, CancellationToken cancellationToken = default + ) => + users.GetUserAsync( + userCredentials.Username!, deadline, userCredentials, + cancellationToken + ); +} diff --git a/src/Kurrent.Client/UserManagement/UserDetails.cs b/src/Kurrent.Client/UserManagement/UserDetails.cs new file mode 100644 index 000000000..b7414dd36 --- /dev/null +++ b/src/Kurrent.Client/UserManagement/UserDetails.cs @@ -0,0 +1,97 @@ +namespace EventStore.Client; + +/// +/// Provides the details for a user. +/// +public readonly struct UserDetails : IEquatable { + /// + /// The users login name. + /// + public readonly string LoginName; + + /// + /// The full name of the user. + /// + public readonly string FullName; + + /// + /// The groups the user is a member of. + /// + public readonly string[] Groups; + + /// + /// The date/time the user was updated in UTC format. + /// + public readonly DateTimeOffset? DateLastUpdated; + + /// + /// Whether the user disable or not. + /// + public readonly bool Disabled; + + /// + /// create a new class. + /// + /// The login name of the user. + /// The users full name. + /// The groups this user is a member if. + /// Is this user disabled or not. + /// The datt/time this user was last updated in UTC format. + public UserDetails( + string loginName, string fullName, string[] groups, bool disabled, DateTimeOffset? dateLastUpdated) { + if (loginName == null) { + throw new ArgumentNullException(nameof(loginName)); + } + + if (fullName == null) { + throw new ArgumentNullException(nameof(fullName)); + } + + if (groups == null) { + throw new ArgumentNullException(nameof(groups)); + } + + LoginName = loginName; + FullName = fullName; + Groups = groups; + Disabled = disabled; + DateLastUpdated = dateLastUpdated; + } + + /// + public bool Equals(UserDetails other) => + LoginName == other.LoginName && FullName == other.FullName && Groups.SequenceEqual(other.Groups) && + Nullable.Equals(DateLastUpdated, other.DateLastUpdated) && Disabled == other.Disabled; + + /// + public override bool Equals(object? obj) => obj is UserDetails other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Hash.Combine(LoginName).Combine(FullName).Combine(Groups) + .Combine(Disabled).Combine(DateLastUpdated); + + /// + /// Compares left and right for equality. + /// + /// + /// + /// True if left is equal to right. + public static bool operator ==(UserDetails left, UserDetails right) => left.Equals(right); + + /// + /// Compares left and right for inequality. + /// + /// + /// + /// True if left is not equal to right. + public static bool operator !=(UserDetails left, UserDetails right) => !left.Equals(right); + + /// + public override string ToString() => + new { + Disabled, + FullName, + LoginName, + Groups = string.Join(",", Groups) + }?.ToString()!; +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 8c770cca2..6630ffb1b 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -30,7 +30,7 @@ - + @@ -39,6 +39,6 @@ - + diff --git a/test/EventStore.Client.Tests.Common/EventStore.Client.Tests.Common.csproj.DotSettings b/test/EventStore.Client.Tests.Common/EventStore.Client.Tests.Common.csproj.DotSettings deleted file mode 100644 index 6ba62e1cd..000000000 --- a/test/EventStore.Client.Tests.Common/EventStore.Client.Tests.Common.csproj.DotSettings +++ /dev/null @@ -1,5 +0,0 @@ - - True - True - True - True \ No newline at end of file diff --git a/test/EventStore.Client.Tests.Common/Extensions/ShouldThrowAsyncExtensions.cs b/test/EventStore.Client.Tests.Common/Extensions/ShouldThrowAsyncExtensions.cs deleted file mode 100644 index edba53a25..000000000 --- a/test/EventStore.Client.Tests.Common/Extensions/ShouldThrowAsyncExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EventStore.Client.Tests; - -public static class ShouldThrowAsyncExtensions { - public static Task ShouldThrowAsync(this EventStoreClient.ReadStreamResult source) where TException : Exception => - source - .ToArrayAsync() - .AsTask() - .ShouldThrowAsync(); - - public static async Task ShouldThrowAsync(this EventStoreClient.ReadStreamResult source, Action handler) where TException : Exception { - var ex = await source.ShouldThrowAsync(); - handler(ex); - } -} diff --git a/test/EventStore.Client.Tests/EventStoreClientOperationsTests.cs b/test/EventStore.Client.Tests/EventStoreClientOperationsTests.cs deleted file mode 100644 index 031a4bfd6..000000000 --- a/test/EventStore.Client.Tests/EventStoreClientOperationsTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EventStore.Client.Tests; - -public class EventStoreClientOperationOptionsTests { - [RetryFact] - public void setting_options_on_clone_should_not_modify_original() { - var options = EventStoreClientOperationOptions.Default; - - var clonedOptions = options.Clone(); - clonedOptions.BatchAppendSize = int.MaxValue; - - Assert.Equal(options.BatchAppendSize, EventStoreClientOperationOptions.Default.BatchAppendSize); - Assert.Equal(int.MaxValue, clonedOptions.BatchAppendSize); - } -} diff --git a/test/EventStore.Client.Tests.Common/.env b/test/Kurrent.Client.Tests.Common/.env similarity index 100% rename from test/EventStore.Client.Tests.Common/.env rename to test/Kurrent.Client.Tests.Common/.env diff --git a/test/EventStore.Client.Tests.Common/ApplicationInfo.cs b/test/Kurrent.Client.Tests.Common/ApplicationInfo.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/ApplicationInfo.cs rename to test/Kurrent.Client.Tests.Common/ApplicationInfo.cs index 0120c21b4..26e78938d 100644 --- a/test/EventStore.Client.Tests.Common/ApplicationInfo.cs +++ b/test/Kurrent.Client.Tests.Common/ApplicationInfo.cs @@ -9,7 +9,7 @@ using static System.Environment; using static System.StringComparison; -namespace EventStore.Client; +namespace Kurrent.Client; /// /// Loads configuration and provides information about the application environment. diff --git a/test/EventStore.Client.Tests.Common/AssertEx.cs b/test/Kurrent.Client.Tests.Common/AssertEx.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/AssertEx.cs rename to test/Kurrent.Client.Tests.Common/AssertEx.cs index db2386374..6750e66b5 100644 --- a/test/EventStore.Client.Tests.Common/AssertEx.cs +++ b/test/Kurrent.Client.Tests.Common/AssertEx.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; using Xunit.Sdk; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public static class AssertEx { /// diff --git a/test/EventStore.Client.Tests.Common/Certificates.cs b/test/Kurrent.Client.Tests.Common/Certificates.cs similarity index 97% rename from test/EventStore.Client.Tests.Common/Certificates.cs rename to test/Kurrent.Client.Tests.Common/Certificates.cs index efd167d67..3b8671d9b 100644 --- a/test/EventStore.Client.Tests.Common/Certificates.cs +++ b/test/Kurrent.Client.Tests.Common/Certificates.cs @@ -1,6 +1,6 @@ // ReSharper disable InconsistentNaming -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public static class Certificates { static readonly string BaseDirectory = AppDomain.CurrentDomain.BaseDirectory; diff --git a/test/EventStore.Client.Tests.Common/Extensions/ConfigurationExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/ConfigurationExtensions.cs similarity index 90% rename from test/EventStore.Client.Tests.Common/Extensions/ConfigurationExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/ConfigurationExtensions.cs index 621758f36..87f06cd45 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/ConfigurationExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/ConfigurationExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Configuration; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public static class ConfigurationExtensions { public static void EnsureValue(this IConfiguration configuration, string key, string defaultValue) { diff --git a/test/EventStore.Client.Tests.Common/Extensions/EventStoreClientExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/KurrentClientExtensions.cs similarity index 69% rename from test/EventStore.Client.Tests.Common/Extensions/EventStoreClientExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/KurrentClientExtensions.cs index cd7b808bd..0b0861cf3 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/EventStoreClientExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/KurrentClientExtensions.cs @@ -1,11 +1,12 @@ +using EventStore.Client; using Polly; using static System.TimeSpan; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; -public static class EventStoreClientExtensions { +public static class KurrentClientExtensions { public static Task CreateUserWithRetry( - this EventStoreUserManagementClient client, string loginName, string fullName, string[] groups, string password, + this KurrentUserManagementClient client, string loginName, string fullName, string[] groups, string password, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default ) => Policy.Handle() diff --git a/test/EventStore.Client.Tests.Common/Extensions/EventStoreClientWarmupExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/KurrentClientWarmupExtensions.cs similarity index 79% rename from test/EventStore.Client.Tests.Common/Extensions/EventStoreClientWarmupExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/KurrentClientWarmupExtensions.cs index aff15195c..89045a44b 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/EventStoreClientWarmupExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/KurrentClientWarmupExtensions.cs @@ -1,11 +1,12 @@ +using EventStore.Client; using Grpc.Core; using Polly; using Polly.Contrib.WaitAndRetry; using static System.TimeSpan; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; -public static class EventStoreClientWarmupExtensions { +public static class KurrentClientWarmupExtensions { static readonly TimeSpan RediscoverTimeout = FromSeconds(5); /// @@ -14,7 +15,7 @@ public static class EventStoreClientWarmupExtensions { static readonly IEnumerable DefaultBackoffDelay = Backoff.ConstantBackoff(FromMilliseconds(100), 300); static async Task TryWarmUp(T client, Func action, CancellationToken cancellationToken = default) - where T : EventStoreClientBase { + where T : KurrentClientBase { await Policy .Handle(ex => ex.StatusCode != StatusCode.Unimplemented) .Or() @@ -39,7 +40,7 @@ await Policy return client; } - public static Task WarmUp(this EventStoreClient client, CancellationToken cancellationToken = default) => + public static Task WarmUp(this KurrentClient client, CancellationToken cancellationToken = default) => TryWarmUp( client, async ct => { @@ -72,7 +73,7 @@ public static Task WarmUp(this EventStoreClient client, Cancel cancellationToken ); - public static Task WarmUp(this EventStoreOperationsClient client, CancellationToken cancellationToken = default) => + public static Task WarmUp(this KurrentOperationsClient client, CancellationToken cancellationToken = default) => TryWarmUp( client, async ct => { @@ -84,8 +85,8 @@ await client.RestartPersistentSubscriptions( cancellationToken ); - public static Task WarmUp( - this EventStorePersistentSubscriptionsClient client, CancellationToken cancellationToken = default + public static Task WarmUp( + this KurrentPersistentSubscriptionsClient client, CancellationToken cancellationToken = default ) => TryWarmUp( client, @@ -102,8 +103,8 @@ await client.CreateToStreamAsync( cancellationToken ); - public static Task WarmUp( - this EventStoreProjectionManagementClient client, CancellationToken cancellationToken = default + public static Task WarmUp( + this KurrentProjectionManagementClient client, CancellationToken cancellationToken = default ) => TryWarmUp( client, @@ -118,7 +119,7 @@ public static Task WarmUp( cancellationToken ); - public static Task WarmUp(this EventStoreUserManagementClient client, CancellationToken cancellationToken = default) => + public static Task WarmUp(this KurrentUserManagementClient client, CancellationToken cancellationToken = default) => TryWarmUp( client, async ct => { diff --git a/test/EventStore.Client.Tests.Common/Extensions/OperatingSystemExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/OperatingSystemExtensions.cs similarity index 88% rename from test/EventStore.Client.Tests.Common/Extensions/OperatingSystemExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/OperatingSystemExtensions.cs index 5899f625c..fbd53f8cc 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/OperatingSystemExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/OperatingSystemExtensions.cs @@ -1,4 +1,4 @@ -namespace EventStore.Client; +namespace Kurrent.Client; public static class OperatingSystemExtensions { public static bool IsWindows(this OperatingSystem operatingSystem) => diff --git a/test/EventStore.Client.Tests.Common/Extensions/ReadOnlyMemoryExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/ReadOnlyMemoryExtensions.cs similarity index 94% rename from test/EventStore.Client.Tests.Common/Extensions/ReadOnlyMemoryExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/ReadOnlyMemoryExtensions.cs index 63b6694a3..403665653 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/ReadOnlyMemoryExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/ReadOnlyMemoryExtensions.cs @@ -1,6 +1,7 @@ using System.Text.Json; +using EventStore.Client; -namespace EventStore.Client; +namespace Kurrent.Client; public static class ReadOnlyMemoryExtensions { public static Position ParsePosition(this ReadOnlyMemory json) { diff --git a/test/Kurrent.Client.Tests.Common/Extensions/ShouldThrowAsyncExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/ShouldThrowAsyncExtensions.cs new file mode 100644 index 000000000..827133337 --- /dev/null +++ b/test/Kurrent.Client.Tests.Common/Extensions/ShouldThrowAsyncExtensions.cs @@ -0,0 +1,16 @@ +using EventStore.Client; + +namespace Kurrent.Client.Tests; + +public static class ShouldThrowAsyncExtensions { + public static Task ShouldThrowAsync(this KurrentClient.ReadStreamResult source) where TException : Exception => + source + .ToArrayAsync() + .AsTask() + .ShouldThrowAsync(); + + public static async Task ShouldThrowAsync(this KurrentClient.ReadStreamResult source, Action handler) where TException : Exception { + var ex = await source.ShouldThrowAsync(); + handler(ex); + } +} diff --git a/test/EventStore.Client.Tests.Common/Extensions/TaskExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/TaskExtensions.cs similarity index 97% rename from test/EventStore.Client.Tests.Common/Extensions/TaskExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/TaskExtensions.cs index d3775b700..9fb022726 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/TaskExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/TaskExtensions.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace EventStore.Client; +namespace Kurrent.Client; public static class TaskExtensions { public static Task WithTimeout(this Task task, TimeSpan timeout) diff --git a/test/EventStore.Client.Tests.Common/Extensions/TypeExtensions.cs b/test/Kurrent.Client.Tests.Common/Extensions/TypeExtensions.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/Extensions/TypeExtensions.cs rename to test/Kurrent.Client.Tests.Common/Extensions/TypeExtensions.cs index 465b04f92..355544e88 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/TypeExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/TypeExtensions.cs @@ -1,6 +1,6 @@ using System.Reflection; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public static class TypeExtensions { public static bool InvokeEqualityOperator(this Type type, object? left, object? right) => type.InvokeOperator("Equality", left, right); diff --git a/test/EventStore.Client.Tests.Common/Extensions/WithExtension.cs b/test/Kurrent.Client.Tests.Common/Extensions/WithExtension.cs similarity index 97% rename from test/EventStore.Client.Tests.Common/Extensions/WithExtension.cs rename to test/Kurrent.Client.Tests.Common/Extensions/WithExtension.cs index 35f731de3..b0c577e26 100644 --- a/test/EventStore.Client.Tests.Common/Extensions/WithExtension.cs +++ b/test/Kurrent.Client.Tests.Common/Extensions/WithExtension.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public static class WithExtension { [DebuggerStepThrough] diff --git a/test/EventStore.Client.Tests.Common/Facts/AnonymousAccess.cs b/test/Kurrent.Client.Tests.Common/Facts/AnonymousAccess.cs similarity index 90% rename from test/EventStore.Client.Tests.Common/Facts/AnonymousAccess.cs rename to test/Kurrent.Client.Tests.Common/Facts/AnonymousAccess.cs index 0e8a3c248..101a67df0 100644 --- a/test/EventStore.Client.Tests.Common/Facts/AnonymousAccess.cs +++ b/test/Kurrent.Client.Tests.Common/Facts/AnonymousAccess.cs @@ -1,4 +1,4 @@ -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [PublicAPI] public class AnonymousAccess { diff --git a/test/EventStore.Client.Tests.Common/Facts/Deprecation.cs b/test/Kurrent.Client.Tests.Common/Facts/Deprecation.cs similarity index 94% rename from test/EventStore.Client.Tests.Common/Facts/Deprecation.cs rename to test/Kurrent.Client.Tests.Common/Facts/Deprecation.cs index 98e9fe671..3b374ee91 100644 --- a/test/EventStore.Client.Tests.Common/Facts/Deprecation.cs +++ b/test/Kurrent.Client.Tests.Common/Facts/Deprecation.cs @@ -1,4 +1,4 @@ -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [PublicAPI] public class Deprecation { diff --git a/test/EventStore.Client.Tests.Common/Facts/Regression.cs b/test/Kurrent.Client.Tests.Common/Facts/Regression.cs similarity index 94% rename from test/EventStore.Client.Tests.Common/Facts/Regression.cs rename to test/Kurrent.Client.Tests.Common/Facts/Regression.cs index 5abdfbff6..09e944518 100644 --- a/test/EventStore.Client.Tests.Common/Facts/Regression.cs +++ b/test/Kurrent.Client.Tests.Common/Facts/Regression.cs @@ -1,4 +1,4 @@ -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [PublicAPI] public class Regression { diff --git a/test/EventStore.Client.Tests.Common/Fakers/TestUserFaker.cs b/test/Kurrent.Client.Tests.Common/Fakers/TestUserFaker.cs similarity index 96% rename from test/EventStore.Client.Tests.Common/Fakers/TestUserFaker.cs rename to test/Kurrent.Client.Tests.Common/Fakers/TestUserFaker.cs index 9cb3c3fbb..472aa1b15 100644 --- a/test/EventStore.Client.Tests.Common/Fakers/TestUserFaker.cs +++ b/test/Kurrent.Client.Tests.Common/Fakers/TestUserFaker.cs @@ -1,4 +1,6 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; public class TestUser { public UserDetails Details { get; set; } = default!; diff --git a/test/EventStore.Client.Tests.Common/Fixtures/BaseTestNode.cs b/test/Kurrent.Client.Tests.Common/Fixtures/BaseTestNode.cs similarity index 97% rename from test/EventStore.Client.Tests.Common/Fixtures/BaseTestNode.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/BaseTestNode.cs index 789384e9d..5ace2a507 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/BaseTestNode.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/BaseTestNode.cs @@ -6,18 +6,18 @@ // using Ductus.FluentDocker.Builders; // using Ductus.FluentDocker.Extensions; // using Ductus.FluentDocker.Services.Extensions; -// using EventStore.Client.Tests.FluentDocker; +// using Kurrent.Client.Tests.FluentDocker; // using Humanizer; // using Serilog; // using Serilog.Extensions.Logging; // using static System.TimeSpan; // -// namespace EventStore.Client.Tests; +// namespace Kurrent.Client.Tests; // // public abstract class BaseTestNode(EventStoreFixtureOptions? options = null) : TestContainerService { // static readonly NetworkPortProvider NetworkPortProvider = new(NetworkPortProvider.DefaultEsdbPort); // -// public EventStoreFixtureOptions Options { get; } = options ?? DefaultOptions(); +// public KurrentFixtureOptions Options { get; } = options ?? DefaultOptions(); // // static Version? _version; // diff --git a/test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs b/test/Kurrent.Client.Tests.Common/Fixtures/CertificatesManager.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/CertificatesManager.cs index 487bfd340..f01d9c246 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/CertificatesManager.cs @@ -1,6 +1,6 @@ using Ductus.FluentDocker.Builders; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; static class CertificatesManager { static readonly DirectoryInfo CertificateDirectory; diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentFixtureOptions.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentFixtureOptions.cs similarity index 86% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentFixtureOptions.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentFixtureOptions.cs index 9e8496d27..51e1e4f78 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentFixtureOptions.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentFixtureOptions.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; public record KurrentFixtureOptions( - EventStoreClientSettings ClientSettings, + KurrentClientSettings ClientSettings, IDictionary Environment ) { public KurrentFixtureOptions WithoutDefaultCredentials() => this with { ClientSettings = ClientSettings.With(x => x.DefaultCredentials = null) }; diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentFixture.Helpers.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentFixture.Helpers.cs similarity index 95% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentFixture.Helpers.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentFixture.Helpers.cs index 41457d3b4..7eb5d9749 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentFixture.Helpers.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentFixture.Helpers.cs @@ -1,14 +1,15 @@ using System.Runtime.CompilerServices; using System.Text; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public partial class KurrentPermanentFixture { public const string TestEventType = "test-event-type"; public const string AnotherTestEventTypePrefix = "another"; public const string AnotherTestEventType = $"{AnotherTestEventTypePrefix}-test-event-type"; - public T NewClient(Action configure) where T : EventStoreClientBase, new() => + public T NewClient(Action configure) where T : KurrentClientBase, new() => (T)Activator.CreateInstance(typeof(T), [ClientSettings.With(configure)])!; public string GetStreamName([CallerMemberName] string? testMethod = null) => diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentFixture.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentFixture.cs similarity index 78% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentFixture.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentFixture.cs index e3c8e426f..2530126e0 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentFixture.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentFixture.cs @@ -2,11 +2,13 @@ using Ductus.FluentDocker.Builders; using Ductus.FluentDocker.Extensions; using Ductus.FluentDocker.Services.Extensions; -using EventStore.Client.Tests.FluentDocker; +using EventStore.Client; +using Kurrent.Client.Tests.FluentDocker; using Serilog; using static System.TimeSpan; +using KurrentClient = EventStore.Client.KurrentClient; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [PublicAPI] public partial class KurrentPermanentFixture : IAsyncLifetime, IAsyncDisposable { @@ -42,11 +44,11 @@ protected KurrentPermanentFixture(ConfigureFixture configure) { public Version EventStoreVersion { get; private set; } = null!; public bool EventStoreHasLastStreamPosition { get; private set; } - public EventStoreClient Streams { get; private set; } = null!; - public EventStoreUserManagementClient Users { get; private set; } = null!; - public EventStoreProjectionManagementClient Projections { get; private set; } = null!; - public EventStorePersistentSubscriptionsClient Subscriptions { get; private set; } = null!; - public EventStoreOperationsClient Operations { get; private set; } = null!; + public KurrentClient Streams { get; private set; } = null!; + public KurrentUserManagementClient Users { get; private set; } = null!; + public KurrentProjectionManagementClient Projections { get; private set; } = null!; + public KurrentPersistentSubscriptionsClient Subscriptions { get; private set; } = null!; + public KurrentOperationsClient Operations { get; private set; } = null!; public bool SkipPsWarmUp { get; set; } @@ -56,7 +58,7 @@ protected KurrentPermanentFixture(ConfigureFixture configure) { /// /// must test this /// - public EventStoreClientSettings ClientSettings => + public KurrentClientSettings ClientSettings => new() { Interceptors = Options.ClientSettings.Interceptors, ConnectionName = Options.ClientSettings.ConnectionName, @@ -99,14 +101,14 @@ public async Task InitializeAsync() { Logger.Warning("*** Warmup started ***"); await Task.WhenAll( - InitClient(async x => Users = await x.WarmUp()), - InitClient(async x => Streams = await x.WarmUp()), - InitClient( + InitClient(async x => Users = await x.WarmUp()), + InitClient(async x => Streams = await x.WarmUp()), + InitClient( async x => Projections = await x.WarmUp(), Options.Environment["EVENTSTORE_RUN_PROJECTIONS"] != "None" ), - InitClient(async x => Subscriptions = SkipPsWarmUp ? x : await x.WarmUp()), - InitClient(async x => Operations = await x.WarmUp()) + InitClient(async x => Subscriptions = SkipPsWarmUp ? x : await x.WarmUp()), + InitClient(async x => Operations = await x.WarmUp()) ); WarmUpCompleted.EnsureCalledOnce(); @@ -123,7 +125,7 @@ await Task.WhenAll( return; - async Task InitClient(Func action, bool execute = true) where T : EventStoreClientBase { + async Task InitClient(Func action, bool execute = true) where T : KurrentClientBase { if (!execute) return default(T)!; var client = (Activator.CreateInstance(typeof(T), ClientSettings) as T)!; @@ -179,8 +181,8 @@ public async Task DisposeAsync() { async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync(); } -public abstract class EventStorePermanentTests : IClassFixture where TFixture : KurrentPermanentFixture { - protected EventStorePermanentTests(ITestOutputHelper output, TFixture fixture) => Fixture = fixture.With(x => x.CaptureTestRun(output)); +public abstract class KurrentPermanentTests : IClassFixture where TFixture : KurrentPermanentFixture { + protected KurrentPermanentTests(ITestOutputHelper output, TFixture fixture) => Fixture = fixture.With(x => x.CaptureTestRun(output)); protected TFixture Fixture { get; } } diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentTestNode.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentTestNode.cs similarity index 97% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentTestNode.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentTestNode.cs index d2c4549ab..e8c554f02 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentPermanentTestNode.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentPermanentTestNode.cs @@ -2,9 +2,9 @@ // using Ductus.FluentDocker.Builders; // using Ductus.FluentDocker.Model.Builders; -// using EventStore.Client.Tests.FluentDocker; +// using Kurrent.Client.Tests.FluentDocker; // -// namespace EventStore.Client.Tests; +// namespace Kurrent.Client.Tests; // // public class EventStorePermanentTestNode(EventStoreFixtureOptions? options = null) : BaseTestNode(options) { // protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder) { @@ -40,8 +40,10 @@ using Ductus.FluentDocker.Model.Builders; using Ductus.FluentDocker.Services.Extensions; using EventStore.Client; -using EventStore.Client.Tests.FluentDocker; +using Kurrent.Client; +using Kurrent.Client.Tests.FluentDocker; using Humanizer; +using Kurrent.Client.Tests; using Serilog; using Serilog.Extensions.Logging; using static System.TimeSpan; @@ -60,7 +62,7 @@ public static KurrentFixtureOptions DefaultOptions() { var port = NetworkPortProvider.NextAvailablePort; - var defaultSettings = EventStoreClientSettings + var defaultSettings = KurrentClientSettings .Create(connString.Replace("{port}", $"{port}")) .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)) .With(x => x.DefaultDeadline = Application.DebuggerIsAttached ? new TimeSpan?() : FromSeconds(30)) diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.Helpers.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.Helpers.cs similarity index 95% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.Helpers.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.Helpers.cs index 3090742b9..31da9f154 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.Helpers.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.Helpers.cs @@ -1,14 +1,15 @@ using System.Runtime.CompilerServices; using System.Text; +using EventStore.Client; -namespace EventStore.Client.Tests.TestNode; +namespace Kurrent.Client.Tests.TestNode; public partial class KurrentTemporaryFixture { public const string TestEventType = "test-event-type"; public const string AnotherTestEventTypePrefix = "another"; public const string AnotherTestEventType = $"{AnotherTestEventTypePrefix}-test-event-type"; - public T NewClient(Action configure) where T : EventStoreClientBase, new() => + public T NewClient(Action configure) where T : KurrentClientBase, new() => (T)Activator.CreateInstance(typeof(T), [ClientSettings.With(configure)])!; public string GetStreamName([CallerMemberName] string? testMethod = null) => diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.cs similarity index 82% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.cs index e7e6bb889..c0c054e1c 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryFixture.cs @@ -4,11 +4,13 @@ using Ductus.FluentDocker.Builders; using Ductus.FluentDocker.Extensions; using Ductus.FluentDocker.Services.Extensions; -using EventStore.Client.Tests.FluentDocker; +using EventStore.Client; +using Kurrent.Client.Tests.FluentDocker; using Serilog; using static System.TimeSpan; +using KurrentClient = EventStore.Client.KurrentClient; -namespace EventStore.Client.Tests.TestNode; +namespace Kurrent.Client.Tests.TestNode; [PublicAPI] public partial class KurrentTemporaryFixture : IAsyncLifetime, IAsyncDisposable { @@ -45,11 +47,11 @@ protected KurrentTemporaryFixture(ConfigureFixture configure) { public Version EventStoreVersion { get; private set; } = null!; public bool EventStoreHasLastStreamPosition { get; private set; } - public EventStoreClient Streams { get; private set; } = null!; - public EventStoreUserManagementClient Users { get; private set; } = null!; - public EventStoreProjectionManagementClient Projections { get; private set; } = null!; - public EventStorePersistentSubscriptionsClient Subscriptions { get; private set; } = null!; - public EventStoreOperationsClient Operations { get; private set; } = null!; + public KurrentClient Streams { get; private set; } = null!; + public KurrentUserManagementClient Users { get; private set; } = null!; + public KurrentProjectionManagementClient Projections { get; private set; } = null!; + public KurrentPersistentSubscriptionsClient Subscriptions { get; private set; } = null!; + public KurrentOperationsClient Operations { get; private set; } = null!; public bool SkipPsWarmUp { get; set; } @@ -59,7 +61,7 @@ protected KurrentTemporaryFixture(ConfigureFixture configure) { /// /// must test this /// - public EventStoreClientSettings ClientSettings => + public KurrentClientSettings ClientSettings => new() { Interceptors = Options.ClientSettings.Interceptors, ConnectionName = Options.ClientSettings.ConnectionName, @@ -101,14 +103,14 @@ public async Task InitializeAsync() { Logger.Warning("*** Warmup started ***"); await Task.WhenAll( - InitClient(async x => Users = await x.WarmUp()), - InitClient(async x => Streams = await x.WarmUp()), - InitClient( + InitClient(async x => Users = await x.WarmUp()), + InitClient(async x => Streams = await x.WarmUp()), + InitClient( async x => Projections = await x.WarmUp(), Options.Environment["EVENTSTORE_RUN_PROJECTIONS"] != "None" ), - InitClient(async x => Subscriptions = SkipPsWarmUp ? x : await x.WarmUp()), - InitClient(async x => Operations = await x.WarmUp()) + InitClient(async x => Subscriptions = SkipPsWarmUp ? x : await x.WarmUp()), + InitClient(async x => Operations = await x.WarmUp()) ); WarmUpCompleted.EnsureCalledOnce(); @@ -125,7 +127,7 @@ await Task.WhenAll( return; - async Task InitClient(Func action, bool execute = true) where T : EventStoreClientBase { + async Task InitClient(Func action, bool execute = true) where T : KurrentClientBase { if (!execute) return default(T)!; var client = (Activator.CreateInstance(typeof(T), ClientSettings) as T)!; diff --git a/test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryTestNode.cs b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryTestNode.cs similarity index 96% rename from test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryTestNode.cs rename to test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryTestNode.cs index ca7ef8cc0..06b3663eb 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/KurrentTemporaryTestNode.cs +++ b/test/Kurrent.Client.Tests.Common/Fixtures/KurrentTemporaryTestNode.cs @@ -2,9 +2,9 @@ // using Ductus.FluentDocker.Builders; // using Ductus.FluentDocker.Model.Builders; -// using EventStore.Client.Tests.FluentDocker; +// using Kurrent.Client.Tests.FluentDocker; // -// namespace EventStore.Client.Tests.TestNode; +// namespace Kurrent.Client.Tests.TestNode; // // public class EventStoreTemporaryTestNode(EventStoreFixtureOptions? options = null) : BaseTestNode(options) { // protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder) { @@ -38,13 +38,14 @@ using Ductus.FluentDocker.Extensions; using Ductus.FluentDocker.Model.Builders; using Ductus.FluentDocker.Services.Extensions; -using EventStore.Client.Tests.FluentDocker; +using EventStore.Client; +using Kurrent.Client.Tests.FluentDocker; using Humanizer; using Serilog; using Serilog.Extensions.Logging; using static System.TimeSpan; -namespace EventStore.Client.Tests.TestNode; +namespace Kurrent.Client.Tests.TestNode; public class KurrentTemporaryTestNode(KurrentFixtureOptions? options = null) : TestContainerService { static readonly NetworkPortProvider NetworkPortProvider = new(NetworkPortProvider.DefaultEsdbPort); @@ -60,7 +61,7 @@ public static KurrentFixtureOptions DefaultOptions() { var port = NetworkPortProvider.NextAvailablePort; - var defaultSettings = EventStoreClientSettings + var defaultSettings = KurrentClientSettings .Create(connString.Replace("{port}", $"{port}")) .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)) .With(x => x.DefaultDeadline = Application.DebuggerIsAttached ? new TimeSpan?() : FromSeconds(30)) diff --git a/test/EventStore.Client.Tests.Common/FluentDocker/FluentDockerBuilderExtensions.cs b/test/Kurrent.Client.Tests.Common/FluentDocker/FluentDockerBuilderExtensions.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/FluentDocker/FluentDockerBuilderExtensions.cs rename to test/Kurrent.Client.Tests.Common/FluentDocker/FluentDockerBuilderExtensions.cs index 3dd450676..506b4e98f 100644 --- a/test/EventStore.Client.Tests.Common/FluentDocker/FluentDockerBuilderExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/FluentDocker/FluentDockerBuilderExtensions.cs @@ -8,7 +8,7 @@ using Polly.Contrib.WaitAndRetry; using static System.TimeSpan; -namespace EventStore.Client.Tests.FluentDocker; +namespace Kurrent.Client.Tests.FluentDocker; public static class FluentDockerBuilderExtensions { public static CompositeBuilder OverrideConfiguration(this CompositeBuilder compositeBuilder, Action configure) { diff --git a/test/EventStore.Client.Tests.Common/FluentDocker/FluentDockerServiceExtensions.cs b/test/Kurrent.Client.Tests.Common/FluentDocker/FluentDockerServiceExtensions.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/FluentDocker/FluentDockerServiceExtensions.cs rename to test/Kurrent.Client.Tests.Common/FluentDocker/FluentDockerServiceExtensions.cs index 0546c4e04..b14bb7a43 100644 --- a/test/EventStore.Client.Tests.Common/FluentDocker/FluentDockerServiceExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/FluentDocker/FluentDockerServiceExtensions.cs @@ -8,7 +8,7 @@ using Ductus.FluentDocker.Services; using Ductus.FluentDocker.Services.Extensions; -namespace EventStore.Client.Tests.FluentDocker; +namespace Kurrent.Client.Tests.FluentDocker; public static class FluentDockerServiceExtensions { static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromMilliseconds(100); diff --git a/test/EventStore.Client.Tests.Common/FluentDocker/TestBypassService.cs b/test/Kurrent.Client.Tests.Common/FluentDocker/TestBypassService.cs similarity index 97% rename from test/EventStore.Client.Tests.Common/FluentDocker/TestBypassService.cs rename to test/Kurrent.Client.Tests.Common/FluentDocker/TestBypassService.cs index 3505eb9af..d09069d48 100644 --- a/test/EventStore.Client.Tests.Common/FluentDocker/TestBypassService.cs +++ b/test/Kurrent.Client.Tests.Common/FluentDocker/TestBypassService.cs @@ -2,7 +2,7 @@ using Ductus.FluentDocker.Common; using Ductus.FluentDocker.Services; -namespace EventStore.Client.Tests.FluentDocker; +namespace Kurrent.Client.Tests.FluentDocker; public class TestBypassService : TestService { protected override BypassBuilder Configure() => throw new NotImplementedException(); diff --git a/test/EventStore.Client.Tests.Common/FluentDocker/TestCompositeService.cs b/test/Kurrent.Client.Tests.Common/FluentDocker/TestCompositeService.cs similarity index 77% rename from test/EventStore.Client.Tests.Common/FluentDocker/TestCompositeService.cs rename to test/Kurrent.Client.Tests.Common/FluentDocker/TestCompositeService.cs index 262133e9f..a321ad77d 100644 --- a/test/EventStore.Client.Tests.Common/FluentDocker/TestCompositeService.cs +++ b/test/Kurrent.Client.Tests.Common/FluentDocker/TestCompositeService.cs @@ -1,6 +1,6 @@ using Ductus.FluentDocker.Builders; using Ductus.FluentDocker.Services; -namespace EventStore.Client.Tests.FluentDocker; +namespace Kurrent.Client.Tests.FluentDocker; public abstract class TestCompositeService : TestService; diff --git a/test/EventStore.Client.Tests.Common/FluentDocker/TestContainerService.cs b/test/Kurrent.Client.Tests.Common/FluentDocker/TestContainerService.cs similarity index 77% rename from test/EventStore.Client.Tests.Common/FluentDocker/TestContainerService.cs rename to test/Kurrent.Client.Tests.Common/FluentDocker/TestContainerService.cs index ae37c353d..fb135276e 100644 --- a/test/EventStore.Client.Tests.Common/FluentDocker/TestContainerService.cs +++ b/test/Kurrent.Client.Tests.Common/FluentDocker/TestContainerService.cs @@ -1,6 +1,6 @@ using Ductus.FluentDocker.Builders; using Ductus.FluentDocker.Services; -namespace EventStore.Client.Tests.FluentDocker; +namespace Kurrent.Client.Tests.FluentDocker; public abstract class TestContainerService : TestService; diff --git a/test/EventStore.Client.Tests.Common/FluentDocker/TestService.cs b/test/Kurrent.Client.Tests.Common/FluentDocker/TestService.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/FluentDocker/TestService.cs rename to test/Kurrent.Client.Tests.Common/FluentDocker/TestService.cs index 890f4e9b6..4bb942517 100644 --- a/test/EventStore.Client.Tests.Common/FluentDocker/TestService.cs +++ b/test/Kurrent.Client.Tests.Common/FluentDocker/TestService.cs @@ -5,7 +5,7 @@ using Serilog; using static Serilog.Core.Constants; -namespace EventStore.Client.Tests.FluentDocker; +namespace Kurrent.Client.Tests.FluentDocker; public interface ITestService : IAsyncDisposable { Task Start(); diff --git a/test/EventStore.Client.Tests.Common/GlobalEnvironment.cs b/test/Kurrent.Client.Tests.Common/GlobalEnvironment.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/GlobalEnvironment.cs rename to test/Kurrent.Client.Tests.Common/GlobalEnvironment.cs index 1fd1ee020..5444a6be4 100644 --- a/test/EventStore.Client.Tests.Common/GlobalEnvironment.cs +++ b/test/Kurrent.Client.Tests.Common/GlobalEnvironment.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Microsoft.Extensions.Configuration; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public static class GlobalEnvironment { static GlobalEnvironment() { diff --git a/test/EventStore.Client.Tests.Common/InterlockedBoolean.cs b/test/Kurrent.Client.Tests.Common/InterlockedBoolean.cs similarity index 100% rename from test/EventStore.Client.Tests.Common/InterlockedBoolean.cs rename to test/Kurrent.Client.Tests.Common/InterlockedBoolean.cs diff --git a/test/EventStore.Client.Tests.Common/EventStore.Client.Tests.Common.csproj b/test/Kurrent.Client.Tests.Common/Kurrent.Client.Tests.Common.csproj similarity index 77% rename from test/EventStore.Client.Tests.Common/EventStore.Client.Tests.Common.csproj rename to test/Kurrent.Client.Tests.Common/Kurrent.Client.Tests.Common.csproj index 6ebee89cb..95d43818b 100644 --- a/test/EventStore.Client.Tests.Common/EventStore.Client.Tests.Common.csproj +++ b/test/Kurrent.Client.Tests.Common/Kurrent.Client.Tests.Common.csproj @@ -1,10 +1,10 @@ - + - EventStore.Client.Tests + Kurrent.Client.Tests - + @@ -60,9 +60,24 @@ Always + + Always + + + Always + + + Always + + + Always + + + Always + - + diff --git a/test/EventStore.Client.Tests.Common/Logging.cs b/test/Kurrent.Client.Tests.Common/Logging.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/Logging.cs rename to test/Kurrent.Client.Tests.Common/Logging.cs index 5742e1134..ee991d064 100644 --- a/test/EventStore.Client.Tests.Common/Logging.cs +++ b/test/Kurrent.Client.Tests.Common/Logging.cs @@ -6,7 +6,7 @@ using Serilog.Formatting.Display; using Xunit.Sdk; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; static class Logging { static readonly Subject LogEventSubject = new(); diff --git a/test/EventStore.Client.Tests.Common/PasswordGenerator.cs b/test/Kurrent.Client.Tests.Common/PasswordGenerator.cs similarity index 98% rename from test/EventStore.Client.Tests.Common/PasswordGenerator.cs rename to test/Kurrent.Client.Tests.Common/PasswordGenerator.cs index f8990b18d..bbf019ce4 100644 --- a/test/EventStore.Client.Tests.Common/PasswordGenerator.cs +++ b/test/Kurrent.Client.Tests.Common/PasswordGenerator.cs @@ -1,6 +1,6 @@ using System.Text; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; static class PasswordGenerator { static PasswordGenerator() { diff --git a/test/EventStore.Client.Tests.Common/Shouldly/ShouldThrowAsyncExtensions.cs b/test/Kurrent.Client.Tests.Common/Shouldly/ShouldThrowAsyncExtensions.cs similarity index 80% rename from test/EventStore.Client.Tests.Common/Shouldly/ShouldThrowAsyncExtensions.cs rename to test/Kurrent.Client.Tests.Common/Shouldly/ShouldThrowAsyncExtensions.cs index f5ec36636..3411cd4b9 100644 --- a/test/EventStore.Client.Tests.Common/Shouldly/ShouldThrowAsyncExtensions.cs +++ b/test/Kurrent.Client.Tests.Common/Shouldly/ShouldThrowAsyncExtensions.cs @@ -7,6 +7,6 @@ namespace Shouldly; [DebuggerStepThrough] public static class ShouldThrowAsyncExtensions { - public static Task ShouldThrowAsync(this EventStoreClient.ReadStreamResult source) where TException : Exception => + public static Task ShouldThrowAsync(this KurrentClient.ReadStreamResult source) where TException : Exception => source.ToArrayAsync().AsTask().ShouldThrowAsync(); } diff --git a/test/EventStore.Client.Tests.Common/TestCaseGenerator.cs b/test/Kurrent.Client.Tests.Common/TestCaseGenerator.cs similarity index 95% rename from test/EventStore.Client.Tests.Common/TestCaseGenerator.cs rename to test/Kurrent.Client.Tests.Common/TestCaseGenerator.cs index 781d19063..b56c95794 100644 --- a/test/EventStore.Client.Tests.Common/TestCaseGenerator.cs +++ b/test/Kurrent.Client.Tests.Common/TestCaseGenerator.cs @@ -1,4 +1,4 @@ -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; using System.Collections; using Bogus; diff --git a/test/EventStore.Client.Tests.Common/TestCredentials.cs b/test/Kurrent.Client.Tests.Common/TestCredentials.cs similarity index 88% rename from test/EventStore.Client.Tests.Common/TestCredentials.cs rename to test/Kurrent.Client.Tests.Common/TestCredentials.cs index b3d075ba4..9f68ea68b 100644 --- a/test/EventStore.Client.Tests.Common/TestCredentials.cs +++ b/test/Kurrent.Client.Tests.Common/TestCredentials.cs @@ -1,4 +1,6 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; public static class TestCredentials { public static readonly UserCredentials Root = new("admin", "changeit"); diff --git a/test/EventStore.Client.Tests.Common/appsettings.Development.json b/test/Kurrent.Client.Tests.Common/appsettings.Development.json similarity index 100% rename from test/EventStore.Client.Tests.Common/appsettings.Development.json rename to test/Kurrent.Client.Tests.Common/appsettings.Development.json diff --git a/test/EventStore.Client.Tests.Common/appsettings.json b/test/Kurrent.Client.Tests.Common/appsettings.json similarity index 100% rename from test/EventStore.Client.Tests.Common/appsettings.json rename to test/Kurrent.Client.Tests.Common/appsettings.json diff --git a/test/EventStore.Client.Tests.Common/docker-compose.certs.yml b/test/Kurrent.Client.Tests.Common/docker-compose.certs.yml similarity index 100% rename from test/EventStore.Client.Tests.Common/docker-compose.certs.yml rename to test/Kurrent.Client.Tests.Common/docker-compose.certs.yml diff --git a/test/EventStore.Client.Tests.Common/docker-compose.cluster.yml b/test/Kurrent.Client.Tests.Common/docker-compose.cluster.yml similarity index 100% rename from test/EventStore.Client.Tests.Common/docker-compose.cluster.yml rename to test/Kurrent.Client.Tests.Common/docker-compose.cluster.yml diff --git a/test/EventStore.Client.Tests.Common/docker-compose.node.yml b/test/Kurrent.Client.Tests.Common/docker-compose.node.yml similarity index 100% rename from test/EventStore.Client.Tests.Common/docker-compose.node.yml rename to test/Kurrent.Client.Tests.Common/docker-compose.node.yml diff --git a/test/EventStore.Client.Tests.Common/docker-compose.yml b/test/Kurrent.Client.Tests.Common/docker-compose.yml similarity index 100% rename from test/EventStore.Client.Tests.Common/docker-compose.yml rename to test/Kurrent.Client.Tests.Common/docker-compose.yml diff --git a/test/EventStore.Client.Tests.Common/shared.env b/test/Kurrent.Client.Tests.Common/shared.env similarity index 100% rename from test/EventStore.Client.Tests.Common/shared.env rename to test/Kurrent.Client.Tests.Common/shared.env diff --git a/test/EventStore.Client.Tests/Assertions/ComparableAssertion.cs b/test/Kurrent.Client.Tests/Assertions/ComparableAssertion.cs similarity index 99% rename from test/EventStore.Client.Tests/Assertions/ComparableAssertion.cs rename to test/Kurrent.Client.Tests/Assertions/ComparableAssertion.cs index a41bd9fa9..cecfe6599 100644 --- a/test/EventStore.Client.Tests/Assertions/ComparableAssertion.cs +++ b/test/Kurrent.Client.Tests/Assertions/ComparableAssertion.cs @@ -3,7 +3,7 @@ using AutoFixture.Kernel; // ReSharper disable once CheckNamespace -namespace EventStore.Client; +namespace Kurrent.Client; class ComparableAssertion : CompositeIdiomaticAssertion { public ComparableAssertion(ISpecimenBuilder builder) : base(CreateChildrenAssertions(builder)) { } diff --git a/test/EventStore.Client.Tests/Assertions/EqualityAssertion.cs b/test/Kurrent.Client.Tests/Assertions/EqualityAssertion.cs similarity index 98% rename from test/EventStore.Client.Tests/Assertions/EqualityAssertion.cs rename to test/Kurrent.Client.Tests/Assertions/EqualityAssertion.cs index 69ab7aed6..6c172af7f 100644 --- a/test/EventStore.Client.Tests/Assertions/EqualityAssertion.cs +++ b/test/Kurrent.Client.Tests/Assertions/EqualityAssertion.cs @@ -2,7 +2,7 @@ using AutoFixture.Kernel; // ReSharper disable once CheckNamespace -namespace EventStore.Client; +namespace Kurrent.Client; class EqualityAssertion : CompositeIdiomaticAssertion { public EqualityAssertion(ISpecimenBuilder builder) : base(CreateChildrenAssertions(builder)) { } diff --git a/test/EventStore.Client.Tests/Assertions/NullArgumentAssertion.cs b/test/Kurrent.Client.Tests/Assertions/NullArgumentAssertion.cs similarity index 97% rename from test/EventStore.Client.Tests/Assertions/NullArgumentAssertion.cs rename to test/Kurrent.Client.Tests/Assertions/NullArgumentAssertion.cs index 866ce8a47..4efb616d5 100644 --- a/test/EventStore.Client.Tests/Assertions/NullArgumentAssertion.cs +++ b/test/Kurrent.Client.Tests/Assertions/NullArgumentAssertion.cs @@ -3,7 +3,7 @@ using AutoFixture.Kernel; // ReSharper disable once CheckNamespace -namespace EventStore.Client; +namespace Kurrent.Client; class NullArgumentAssertion : IdiomaticAssertion { readonly ISpecimenBuilder _builder; diff --git a/test/EventStore.Client.Tests/Assertions/StringConversionAssertion.cs b/test/Kurrent.Client.Tests/Assertions/StringConversionAssertion.cs similarity index 97% rename from test/EventStore.Client.Tests/Assertions/StringConversionAssertion.cs rename to test/Kurrent.Client.Tests/Assertions/StringConversionAssertion.cs index 302803c51..fa4da4970 100644 --- a/test/EventStore.Client.Tests/Assertions/StringConversionAssertion.cs +++ b/test/Kurrent.Client.Tests/Assertions/StringConversionAssertion.cs @@ -3,7 +3,7 @@ using AutoFixture.Kernel; // ReSharper disable once CheckNamespace -namespace EventStore.Client; +namespace Kurrent.Client; class StringConversionAssertion : IdiomaticAssertion { readonly ISpecimenBuilder _builder; diff --git a/test/EventStore.Client.Tests/Assertions/ValueObjectAssertion.cs b/test/Kurrent.Client.Tests/Assertions/ValueObjectAssertion.cs similarity index 95% rename from test/EventStore.Client.Tests/Assertions/ValueObjectAssertion.cs rename to test/Kurrent.Client.Tests/Assertions/ValueObjectAssertion.cs index 3ff92b7c9..ee3fce80d 100644 --- a/test/EventStore.Client.Tests/Assertions/ValueObjectAssertion.cs +++ b/test/Kurrent.Client.Tests/Assertions/ValueObjectAssertion.cs @@ -2,7 +2,7 @@ using AutoFixture.Kernel; // ReSharper disable once CheckNamespace -namespace EventStore.Client; +namespace Kurrent.Client; class ValueObjectAssertion : CompositeIdiomaticAssertion { public ValueObjectAssertion(ISpecimenBuilder builder) : base(CreateChildrenAssertions(builder)) { } diff --git a/test/EventStore.Client.Tests/AutoScenarioDataAttribute.cs b/test/Kurrent.Client.Tests/AutoScenarioDataAttribute.cs similarity index 95% rename from test/EventStore.Client.Tests/AutoScenarioDataAttribute.cs rename to test/Kurrent.Client.Tests/AutoScenarioDataAttribute.cs index 1bad5bce5..166e5b7ef 100644 --- a/test/EventStore.Client.Tests/AutoScenarioDataAttribute.cs +++ b/test/Kurrent.Client.Tests/AutoScenarioDataAttribute.cs @@ -3,7 +3,7 @@ using AutoFixture.Xunit2; using Xunit.Sdk; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [DataDiscoverer("AutoFixture.Xunit2.NoPreDiscoveryDataDiscoverer", "AutoFixture.Xunit2")] public class AutoScenarioDataAttribute : DataAttribute { diff --git a/test/EventStore.Client.Tests/ClientCertificatesTests.cs b/test/Kurrent.Client.Tests/ClientCertificatesTests.cs similarity index 88% rename from test/EventStore.Client.Tests/ClientCertificatesTests.cs rename to test/Kurrent.Client.Tests/ClientCertificatesTests.cs index e2e1a248c..61ec9dcd4 100644 --- a/test/EventStore.Client.Tests/ClientCertificatesTests.cs +++ b/test/Kurrent.Client.Tests/ClientCertificatesTests.cs @@ -1,21 +1,22 @@ +using EventStore.Client; using Humanizer; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:Plugins")] [Trait("Category", "Type:UserCertificate")] public class ClientCertificateTests(ITestOutputHelper output, KurrentPermanentFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [SupportsPlugins.Theory(EventStoreRepository.Commercial, "This server version does not support plugins"), BadClientCertificatesTestCases] async Task bad_certificates_combinations_should_return_authentication_error(string userCertFile, string userKeyFile, string tlsCaFile) { var stream = Fixture.GetStreamName(); var seedEvents = Fixture.CreateTestEvents(); var connectionString = $"esdb://localhost:2113/?tls=true&userCertFile={userCertFile}&userKeyFile={userKeyFile}&tlsCaFile={tlsCaFile}"; - var settings = EventStoreClientSettings.Create(connectionString); + var settings = KurrentClientSettings.Create(connectionString); settings.ConnectivitySettings.TlsVerifyCert.ShouldBeTrue(); - await using var client = new EventStoreClient(settings); + await using var client = new KurrentClient(settings); await client.AppendToStreamAsync(stream, StreamState.NoStream, seedEvents).ShouldThrowAsync(); } @@ -26,10 +27,10 @@ async Task valid_certificates_combinations_should_write_to_stream(string userCer var seedEvents = Fixture.CreateTestEvents(); var connectionString = $"esdb://localhost:2113/?userCertFile={userCertFile}&userKeyFile={userKeyFile}&tlsCaFile={tlsCaFile}"; - var settings = EventStoreClientSettings.Create(connectionString); + var settings = KurrentClientSettings.Create(connectionString); settings.ConnectivitySettings.TlsVerifyCert.ShouldBeTrue(); - await using var client = new EventStoreClient(settings); + await using var client = new KurrentClient(settings); var result = await client.AppendToStreamAsync(stream, StreamState.NoStream, seedEvents); result.ShouldNotBeNull(); @@ -41,10 +42,10 @@ async Task basic_authentication_should_take_precedence(string userCertFile, stri var seedEvents = Fixture.CreateTestEvents(); var connectionString = $"esdb://admin:changeit@localhost:2113/?userCertFile={userCertFile}&userKeyFile={userKeyFile}&tlsCaFile={tlsCaFile}"; - var settings = EventStoreClientSettings.Create(connectionString); + var settings = KurrentClientSettings.Create(connectionString); settings.ConnectivitySettings.TlsVerifyCert.ShouldBeTrue(); - await using var client = new EventStoreClient(settings); + await using var client = new KurrentClient(settings); var result = await client.AppendToStreamAsync(stream, StreamState.NoStream, seedEvents); result.ShouldNotBeNull(); diff --git a/test/EventStore.Client.Tests/ConnectionStringTests.cs b/test/Kurrent.Client.Tests/ConnectionStringTests.cs similarity index 75% rename from test/EventStore.Client.Tests/ConnectionStringTests.cs rename to test/Kurrent.Client.Tests/ConnectionStringTests.cs index 15b86703c..2d3d09ab2 100644 --- a/test/EventStore.Client.Tests/ConnectionStringTests.cs +++ b/test/Kurrent.Client.Tests/ConnectionStringTests.cs @@ -1,13 +1,12 @@ using System.Net; +using System.Net.Http; using System.Reflection; using System.Security.Cryptography.X509Certificates; using AutoFixture; +using EventStore.Client; +using HashCode = EventStore.Client.HashCode; -#if NET48 -using System.Net.Http; -#endif - -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class ConnectionStringTests { public static IEnumerable ValidCases() { @@ -27,14 +26,14 @@ public class ConnectionStringTests { return Enumerable.Range(0, 3).SelectMany(GetTestCases); IEnumerable GetTestCases(int _) { - var settings = new EventStoreClientSettings { + var settings = new KurrentClientSettings { ConnectionName = fixture.Create(), - ConnectivitySettings = fixture.Create(), - OperationOptions = fixture.Create() + ConnectivitySettings = fixture.Create(), + OperationOptions = fixture.Create() }; settings.ConnectivitySettings.Address = - new UriBuilder(EventStoreClientConnectivitySettings.Default.ResolvedAddressOrDefault) { + new UriBuilder(KurrentClientConnectivitySettings.Default.ResolvedAddressOrDefault) { Scheme = settings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme }.Uri; @@ -48,14 +47,14 @@ public class ConnectionStringTests { settings }; - var ipGossipSettings = new EventStoreClientSettings { + var ipGossipSettings = new KurrentClientSettings { ConnectionName = fixture.Create(), - ConnectivitySettings = fixture.Create(), - OperationOptions = fixture.Create() + ConnectivitySettings = fixture.Create(), + OperationOptions = fixture.Create() }; ipGossipSettings.ConnectivitySettings.Address = - new UriBuilder(EventStoreClientConnectivitySettings.Default.ResolvedAddressOrDefault) { + new UriBuilder(KurrentClientConnectivitySettings.Default.ResolvedAddressOrDefault) { Scheme = ipGossipSettings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme }.Uri; @@ -71,10 +70,10 @@ public class ConnectionStringTests { ipGossipSettings }; - var singleNodeSettings = new EventStoreClientSettings { + var singleNodeSettings = new KurrentClientSettings { ConnectionName = fixture.Create(), - ConnectivitySettings = fixture.Create(), - OperationOptions = fixture.Create() + ConnectivitySettings = fixture.Create(), + OperationOptions = fixture.Create() }; singleNodeSettings.ConnectivitySettings.DnsGossipSeeds = null; @@ -99,18 +98,18 @@ public class ConnectionStringTests { [Theory] [MemberData(nameof(ValidCases))] - public void valid_connection_string(string connectionString, EventStoreClientSettings expected) { - var result = EventStoreClientSettings.Create(connectionString); + public void valid_connection_string(string connectionString, KurrentClientSettings expected) { + var result = KurrentClientSettings.Create(connectionString); - Assert.Equal(expected, result, EventStoreClientSettingsEqualityComparer.Instance); + Assert.Equal(expected, result, KurrentClientSettingsEqualityComparer.Instance); } [Theory] [MemberData(nameof(ValidCases))] - public void valid_connection_string_with_empty_path(string connectionString, EventStoreClientSettings expected) { - var result = EventStoreClientSettings.Create(connectionString.Replace("?", "/?")); + public void valid_connection_string_with_empty_path(string connectionString, KurrentClientSettings expected) { + var result = KurrentClientSettings.Create(connectionString.Replace("?", "/?")); - Assert.Equal(expected, result, EventStoreClientSettingsEqualityComparer.Instance); + Assert.Equal(expected, result, KurrentClientSettingsEqualityComparer.Instance); } #if !GRPC_CORE @@ -119,7 +118,7 @@ public void valid_connection_string_with_empty_path(string connectionString, Eve [InlineData(true)] public void tls_verify_cert(bool tlsVerifyCert) { var connectionString = $"esdb://localhost:2113/?tlsVerifyCert={tlsVerifyCert}"; - var result = EventStoreClientSettings.Create(connectionString); + var result = KurrentClientSettings.Create(connectionString); using var handler = result.CreateHttpMessageHandler?.Invoke(); #if NET var socketsHandler = Assert.IsType(handler); @@ -159,7 +158,7 @@ public void tls_verify_cert(bool tlsVerifyCert) { [MemberData(nameof(InvalidTlsCertificates))] public void connection_string_with_invalid_tls_certificate_should_throw(string clientCertificatePath) { Assert.Throws( - () => EventStoreClientSettings.Create($"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={clientCertificatePath}") + () => KurrentClientSettings.Create($"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={clientCertificatePath}") ); } @@ -174,7 +173,7 @@ public void connection_string_with_invalid_tls_certificate_should_throw(string c [MemberData(nameof(InvalidClientCertificates))] public void connection_string_with_invalid_client_certificate_should_throw(string userCertFile, string userKeyFile) { Assert.Throws( - () => EventStoreClientSettings.Create( + () => KurrentClientSettings.Create( $"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&userCertFile={userCertFile}&userKeyFile={userKeyFile}" ) ); @@ -182,7 +181,7 @@ public void connection_string_with_invalid_client_certificate_should_throw(strin [RetryFact] public void infinite_grpc_timeouts() { - var result = EventStoreClientSettings.Create("esdb://localhost:2113?keepAliveInterval=-1&keepAliveTimeout=-1"); + var result = KurrentClientSettings.Create("esdb://localhost:2113?keepAliveInterval=-1&keepAliveTimeout=-1"); Assert.Equal(System.Threading.Timeout.InfiniteTimeSpan, result.ConnectivitySettings.KeepAliveInterval); Assert.Equal(System.Threading.Timeout.InfiniteTimeSpan, result.ConnectivitySettings.KeepAliveTimeout); @@ -201,21 +200,21 @@ public void infinite_grpc_timeouts() { } [RetryFact] - public void connection_string_with_no_schema() => Assert.Throws(() => EventStoreClientSettings.Create(":so/mething/random")); + public void connection_string_with_no_schema() => Assert.Throws(() => KurrentClientSettings.Create(":so/mething/random")); [Theory] [InlineData("esdbwrong://")] [InlineData("wrong://")] [InlineData("badesdb://")] public void connection_string_with_invalid_scheme_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [Theory] [InlineData("esdb://userpass@127.0.0.1/")] [InlineData("esdb://user:pa:ss@127.0.0.1/")] [InlineData("esdb://us:er:pa:ss@127.0.0.1/")] public void connection_string_with_invalid_userinfo_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [Theory] [InlineData("esdb://user:pass@127.0.0.1:abc")] @@ -229,14 +228,14 @@ public void connection_string_with_invalid_userinfo_should_throw(string connecti [InlineData("esdb://user:pass@localhost:1234,,127.0.0.3:4321")] [InlineData("esdb://user:pass@localhost:1234,,127.0.0.3:4321/")] public void connection_string_with_invalid_host_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [Theory] [InlineData("esdb://user:pass@127.0.0.1/test")] [InlineData("esdb://user:pass@127.0.0.1/maxDiscoverAttempts=10")] [InlineData("esdb://user:pass@127.0.0.1/hello?maxDiscoverAttempts=10")] public void connection_string_with_non_empty_path_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [Theory] [InlineData("esdb://user:pass@127.0.0.1")] @@ -244,19 +243,19 @@ public void connection_string_with_non_empty_path_should_throw(string connection [InlineData("esdb+discover://user:pass@127.0.0.1")] [InlineData("esdb+discover://user:pass@127.0.0.1/")] public void connection_string_with_no_key_value_pairs_specified_should_not_throw(string connectionString) => - EventStoreClientSettings.Create(connectionString); + KurrentClientSettings.Create(connectionString); [Theory] [InlineData("esdb://user:pass@127.0.0.1/?maxDiscoverAttempts=12=34")] [InlineData("esdb://user:pass@127.0.0.1/?maxDiscoverAttempts1234")] public void connection_string_with_invalid_key_value_pair_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [Theory] [InlineData("esdb://user:pass@127.0.0.1/?maxDiscoverAttempts=1234&MaxDiscoverAttempts=10")] [InlineData("esdb://user:pass@127.0.0.1/?gossipTimeout=10&gossipTimeout=30")] public void connection_string_with_duplicate_key_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [Theory] [InlineData("esdb://user:pass@127.0.0.1/?unknown=1234")] @@ -269,59 +268,59 @@ public void connection_string_with_duplicate_key_should_throw(string connectionS [InlineData("esdb://user:pass@127.0.0.1/?keepAliveInterval=-2")] [InlineData("esdb://user:pass@127.0.0.1/?keepAliveTimeout=-2")] public void connection_string_with_invalid_settings_should_throw(string connectionString) => - Assert.Throws(() => EventStoreClientSettings.Create(connectionString)); + Assert.Throws(() => KurrentClientSettings.Create(connectionString)); [RetryFact] public void with_default_settings() { - var settings = EventStoreClientSettings.Create("esdb://hostname:4321/"); + var settings = KurrentClientSettings.Create("esdb://hostname:4321/"); Assert.Null(settings.ConnectionName); Assert.Equal( - EventStoreClientConnectivitySettings.Default.ResolvedAddressOrDefault.Scheme, + KurrentClientConnectivitySettings.Default.ResolvedAddressOrDefault.Scheme, settings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme ); Assert.Equal( - EventStoreClientConnectivitySettings.Default.DiscoveryInterval.TotalMilliseconds, + KurrentClientConnectivitySettings.Default.DiscoveryInterval.TotalMilliseconds, settings.ConnectivitySettings.DiscoveryInterval.TotalMilliseconds ); - Assert.Null(EventStoreClientConnectivitySettings.Default.DnsGossipSeeds); - Assert.Empty(EventStoreClientConnectivitySettings.Default.GossipSeeds); + Assert.Null(KurrentClientConnectivitySettings.Default.DnsGossipSeeds); + Assert.Empty(KurrentClientConnectivitySettings.Default.GossipSeeds); Assert.Equal( - EventStoreClientConnectivitySettings.Default.GossipTimeout.TotalMilliseconds, + KurrentClientConnectivitySettings.Default.GossipTimeout.TotalMilliseconds, settings.ConnectivitySettings.GossipTimeout.TotalMilliseconds ); - Assert.Null(EventStoreClientConnectivitySettings.Default.IpGossipSeeds); + Assert.Null(KurrentClientConnectivitySettings.Default.IpGossipSeeds); Assert.Equal( - EventStoreClientConnectivitySettings.Default.MaxDiscoverAttempts, + KurrentClientConnectivitySettings.Default.MaxDiscoverAttempts, settings.ConnectivitySettings.MaxDiscoverAttempts ); Assert.Equal( - EventStoreClientConnectivitySettings.Default.NodePreference, + KurrentClientConnectivitySettings.Default.NodePreference, settings.ConnectivitySettings.NodePreference ); Assert.Equal( - EventStoreClientConnectivitySettings.Default.Insecure, + KurrentClientConnectivitySettings.Default.Insecure, settings.ConnectivitySettings.Insecure ); Assert.Equal(TimeSpan.FromSeconds(10), settings.DefaultDeadline); Assert.Equal( - EventStoreClientOperationOptions.Default.ThrowOnAppendFailure, + KurrentClientOperationOptions.Default.ThrowOnAppendFailure, settings.OperationOptions.ThrowOnAppendFailure ); Assert.Equal( - EventStoreClientConnectivitySettings.Default.KeepAliveInterval, + KurrentClientConnectivitySettings.Default.KeepAliveInterval, settings.ConnectivitySettings.KeepAliveInterval ); Assert.Equal( - EventStoreClientConnectivitySettings.Default.KeepAliveTimeout, + KurrentClientConnectivitySettings.Default.KeepAliveTimeout, settings.ConnectivitySettings.KeepAliveTimeout ); } @@ -334,7 +333,7 @@ public void with_default_settings() { [InlineData("esdb://localhost1,localhost2,localhost3/?tls=false", false)] [InlineData("esdb://localhost1,localhost2,localhost3/?tls=true", true)] public void use_tls(string connectionString, bool expectedUseTls) { - var result = EventStoreClientSettings.Create(connectionString); + var result = KurrentClientSettings.Create(connectionString); var expectedScheme = expectedUseTls ? "https" : "http"; Assert.NotEqual(expectedUseTls, result.ConnectivitySettings.Insecure); Assert.Equal(expectedScheme, result.ConnectivitySettings.ResolvedAddressOrDefault.Scheme); @@ -360,7 +359,7 @@ public void use_tls(string connectionString, bool expectedUseTls) { [InlineData("esdb://localhost1,localhost2,localhost3/?tls=false", true, false)] [InlineData("esdb://localhost1,localhost2,localhost3/?tls=false", false, false)] public void allow_tls_override_for_single_node(string connectionString, bool? insecureOverride, bool expectedUseTls) { - var result = EventStoreClientSettings.Create(connectionString); + var result = KurrentClientSettings.Create(connectionString); var settings = result.ConnectivitySettings; if (insecureOverride.HasValue) @@ -379,7 +378,7 @@ public void allow_tls_override_for_single_node(string connectionString, bool? in [InlineData("esdb+discover://localhost:1234", null, null)] [InlineData("esdb+discover://localhost:1234,localhost:4567", null, null)] public void connection_string_with_custom_ports(string connectionString, string? expectedHost, int? expectedPort) { - var result = EventStoreClientSettings.Create(connectionString); + var result = KurrentClientSettings.Create(connectionString); var connectivitySettings = result.ConnectivitySettings; Assert.Equal(expectedHost, connectivitySettings.Address?.Host); @@ -387,17 +386,17 @@ public void connection_string_with_custom_ports(string connectionString, string? } static string GetConnectionString( - EventStoreClientSettings settings, + KurrentClientSettings settings, Func? getKey = default ) => $"{GetScheme(settings)}{GetAuthority(settings)}?{GetKeyValuePairs(settings, getKey)}"; - static string GetScheme(EventStoreClientSettings settings) => + static string GetScheme(KurrentClientSettings settings) => settings.ConnectivitySettings.IsSingleNode ? "esdb://" : "esdb+discover://"; - static string GetAuthority(EventStoreClientSettings settings) => + static string GetAuthority(KurrentClientSettings settings) => settings.ConnectivitySettings.IsSingleNode ? $"{settings.ConnectivitySettings.ResolvedAddressOrDefault.Host}:{settings.ConnectivitySettings.ResolvedAddressOrDefault.Port}" : string.Join( @@ -406,7 +405,7 @@ static string GetAuthority(EventStoreClientSettings settings) => ); static string GetKeyValuePairs( - EventStoreClientSettings settings, + KurrentClientSettings settings, Func? getKey = default ) { var pairs = new Dictionary { @@ -444,10 +443,10 @@ static string GetKeyValuePairs( return string.Join("&", pairs.Select(pair => $"{getKey?.Invoke(pair.Key) ?? pair.Key}={pair.Value}")); } - class EventStoreClientSettingsEqualityComparer : IEqualityComparer { - public static readonly EventStoreClientSettingsEqualityComparer Instance = new(); + class KurrentClientSettingsEqualityComparer : IEqualityComparer { + public static readonly KurrentClientSettingsEqualityComparer Instance = new(); - public bool Equals(EventStoreClientSettings? x, EventStoreClientSettings? y) { + public bool Equals(KurrentClientSettings? x, KurrentClientSettings? y) { if (ReferenceEquals(x, y)) return true; @@ -461,29 +460,29 @@ public bool Equals(EventStoreClientSettings? x, EventStoreClientSettings? y) { return false; return x.ConnectionName == y.ConnectionName && - EventStoreClientConnectivitySettingsEqualityComparer.Instance.Equals( + KurrentClientConnectivitySettingsEqualityComparer.Instance.Equals( x.ConnectivitySettings, y.ConnectivitySettings ) && - EventStoreClientOperationOptionsEqualityComparer.Instance.Equals( + KurrentClientOperationOptionsEqualityComparer.Instance.Equals( x.OperationOptions, y.OperationOptions ) && Equals(x.DefaultCredentials?.ToString(), y.DefaultCredentials?.ToString()); } - public int GetHashCode(EventStoreClientSettings obj) => + public int GetHashCode(KurrentClientSettings obj) => HashCode.Hash .Combine(obj.ConnectionName) - .Combine(EventStoreClientConnectivitySettingsEqualityComparer.Instance.GetHashCode(obj.ConnectivitySettings)) - .Combine(EventStoreClientOperationOptionsEqualityComparer.Instance.GetHashCode(obj.OperationOptions)); + .Combine(KurrentClientConnectivitySettingsEqualityComparer.Instance.GetHashCode(obj.ConnectivitySettings)) + .Combine(KurrentClientOperationOptionsEqualityComparer.Instance.GetHashCode(obj.OperationOptions)); } - class EventStoreClientConnectivitySettingsEqualityComparer - : IEqualityComparer { - public static readonly EventStoreClientConnectivitySettingsEqualityComparer Instance = new(); + class KurrentClientConnectivitySettingsEqualityComparer + : IEqualityComparer { + public static readonly KurrentClientConnectivitySettingsEqualityComparer Instance = new(); - public bool Equals(EventStoreClientConnectivitySettings? x, EventStoreClientConnectivitySettings? y) { + public bool Equals(KurrentClientConnectivitySettings? x, KurrentClientConnectivitySettings? y) { if (ReferenceEquals(x, y)) return true; @@ -507,7 +506,7 @@ public bool Equals(EventStoreClientConnectivitySettings? x, EventStoreClientConn x.Insecure == y.Insecure; } - public int GetHashCode(EventStoreClientConnectivitySettings obj) => + public int GetHashCode(KurrentClientConnectivitySettings obj) => obj.GossipSeeds.Aggregate( HashCode.Hash .Combine(obj.ResolvedAddressOrDefault.GetHashCode()) @@ -522,11 +521,11 @@ public int GetHashCode(EventStoreClientConnectivitySettings obj) => ); } - class EventStoreClientOperationOptionsEqualityComparer - : IEqualityComparer { - public static readonly EventStoreClientOperationOptionsEqualityComparer Instance = new(); + class KurrentClientOperationOptionsEqualityComparer + : IEqualityComparer { + public static readonly KurrentClientOperationOptionsEqualityComparer Instance = new(); - public bool Equals(EventStoreClientOperationOptions? x, EventStoreClientOperationOptions? y) { + public bool Equals(KurrentClientOperationOptions? x, KurrentClientOperationOptions? y) { if (ReferenceEquals(x, y)) return true; @@ -539,7 +538,7 @@ public bool Equals(EventStoreClientOperationOptions? x, EventStoreClientOperatio return x.GetType() == y.GetType(); } - public int GetHashCode(EventStoreClientOperationOptions obj) => + public int GetHashCode(KurrentClientOperationOptions obj) => System.HashCode.Combine(obj.ThrowOnAppendFailure); } } diff --git a/test/EventStore.Client.Tests/FromAllTests.cs b/test/Kurrent.Client.Tests/FromAllTests.cs similarity index 96% rename from test/EventStore.Client.Tests/FromAllTests.cs rename to test/Kurrent.Client.Tests/FromAllTests.cs index ae00af5dc..93adcf189 100644 --- a/test/EventStore.Client.Tests/FromAllTests.cs +++ b/test/Kurrent.Client.Tests/FromAllTests.cs @@ -1,6 +1,7 @@ using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class FromAllTests : ValueObjectTests { public FromAllTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/FromStreamTests.cs b/test/Kurrent.Client.Tests/FromStreamTests.cs similarity index 96% rename from test/EventStore.Client.Tests/FromStreamTests.cs rename to test/Kurrent.Client.Tests/FromStreamTests.cs index e6e1c08f8..2b0df8dae 100644 --- a/test/EventStore.Client.Tests/FromStreamTests.cs +++ b/test/Kurrent.Client.Tests/FromStreamTests.cs @@ -1,6 +1,7 @@ using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class FromStreamTests : ValueObjectTests { public FromStreamTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/GossipChannelSelectorTests.cs b/test/Kurrent.Client.Tests/GossipChannelSelectorTests.cs similarity index 94% rename from test/EventStore.Client.Tests/GossipChannelSelectorTests.cs rename to test/Kurrent.Client.Tests/GossipChannelSelectorTests.cs index 00cdc0e0f..142a7dc04 100644 --- a/test/EventStore.Client.Tests/GossipChannelSelectorTests.cs +++ b/test/Kurrent.Client.Tests/GossipChannelSelectorTests.cs @@ -1,7 +1,8 @@ using System.Net; +using EventStore.Client; using Grpc.Core; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class GossipChannelSelectorTests { [RetryFact] @@ -12,7 +13,7 @@ public async Task ExplicitlySettingEndPointChangesChannels() { var firstSelection = new DnsEndPoint(firstId.ToString(), 2113); var secondSelection = new DnsEndPoint(secondId.ToString(), 2113); - var settings = new EventStoreClientSettings { + var settings = new KurrentClientSettings { ConnectivitySettings = { DnsGossipSeeds = new[] { firstSelection, @@ -55,7 +56,7 @@ public async Task ExplicitlySettingEndPointChangesChannels() { [RetryFact] public async Task ThrowsWhenDiscoveryFails() { - var settings = new EventStoreClientSettings { + var settings = new KurrentClientSettings { ConnectivitySettings = { IpGossipSeeds = new[] { new IPEndPoint(IPAddress.Loopback, 2113) diff --git a/test/EventStore.Client.Tests/GrpcServerCapabilitiesClientTests.cs b/test/Kurrent.Client.Tests/GrpcServerCapabilitiesClientTests.cs similarity index 97% rename from test/EventStore.Client.Tests/GrpcServerCapabilitiesClientTests.cs rename to test/Kurrent.Client.Tests/GrpcServerCapabilitiesClientTests.cs index b1b5014df..1bb44dc60 100644 --- a/test/EventStore.Client.Tests/GrpcServerCapabilitiesClientTests.cs +++ b/test/Kurrent.Client.Tests/GrpcServerCapabilitiesClientTests.cs @@ -1,13 +1,13 @@ // #if NET // using System.Net; -// using EventStore.Client.ServerFeatures; +// using Kurrent.Client.ServerFeatures; // using Grpc.Core; // using Microsoft.AspNetCore.Builder; // using Microsoft.AspNetCore.Hosting; // using Microsoft.AspNetCore.TestHost; // using Microsoft.Extensions.DependencyInjection; // -// namespace EventStore.Client.Tests; +// namespace Kurrent.Client.Tests; // // public class GrpcServerCapabilitiesClientTests { // public static IEnumerable ExpectedResultsCases() { diff --git a/test/EventStore.Client.Tests/EventStore.Client.Tests.csproj b/test/Kurrent.Client.Tests/Kurrent.Client.Tests.csproj similarity index 70% rename from test/EventStore.Client.Tests/EventStore.Client.Tests.csproj rename to test/Kurrent.Client.Tests/Kurrent.Client.Tests.csproj index c4456169e..ffca50910 100644 --- a/test/EventStore.Client.Tests/EventStore.Client.Tests.csproj +++ b/test/Kurrent.Client.Tests/Kurrent.Client.Tests.csproj @@ -1,10 +1,7 @@  - - - - + @@ -23,4 +20,4 @@ - \ No newline at end of file + diff --git a/test/Kurrent.Client.Tests/KurrentClientOperationsTests.cs b/test/Kurrent.Client.Tests/KurrentClientOperationsTests.cs new file mode 100644 index 000000000..07f5ffb46 --- /dev/null +++ b/test/Kurrent.Client.Tests/KurrentClientOperationsTests.cs @@ -0,0 +1,16 @@ +using EventStore.Client; + +namespace Kurrent.Client.Tests; + +public class KurrentClientOperationOptionsTests { + [RetryFact] + public void setting_options_on_clone_should_not_modify_original() { + var options = KurrentClientOperationOptions.Default; + + var clonedOptions = options.Clone(); + clonedOptions.BatchAppendSize = int.MaxValue; + + Assert.Equal(options.BatchAppendSize, KurrentClientOperationOptions.Default.BatchAppendSize); + Assert.Equal(int.MaxValue, clonedOptions.BatchAppendSize); + } +} diff --git a/test/EventStore.Client.Tests/NodePreferenceComparerTests.cs b/test/Kurrent.Client.Tests/NodePreferenceComparerTests.cs similarity index 97% rename from test/EventStore.Client.Tests/NodePreferenceComparerTests.cs rename to test/Kurrent.Client.Tests/NodePreferenceComparerTests.cs index abbec81a1..58300f00f 100644 --- a/test/EventStore.Client.Tests/NodePreferenceComparerTests.cs +++ b/test/Kurrent.Client.Tests/NodePreferenceComparerTests.cs @@ -1,6 +1,7 @@ +using EventStore.Client; using static EventStore.Client.ClusterMessages.VNodeState; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class NodePreferenceComparerTests { static ClusterMessages.VNodeState RunTest(IComparer sut, params ClusterMessages.VNodeState[] states) => diff --git a/test/EventStore.Client.Tests/NodeSelectorTests.cs b/test/Kurrent.Client.Tests/NodeSelectorTests.cs similarity index 94% rename from test/EventStore.Client.Tests/NodeSelectorTests.cs rename to test/Kurrent.Client.Tests/NodeSelectorTests.cs index 19855b362..5f0eb51ba 100644 --- a/test/EventStore.Client.Tests/NodeSelectorTests.cs +++ b/test/Kurrent.Client.Tests/NodeSelectorTests.cs @@ -1,6 +1,7 @@ using System.Net; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class NodeSelectorTests { static readonly ClusterMessages.VNodeState[] NotAllowedStates = { @@ -26,7 +27,7 @@ public class NodeSelectorTests { var notAllowedNodeId = Uuid.NewUuid(); var notAllowedNode = new DnsEndPoint(notAllowedNodeId.ToString(), 2114); - var settings = new EventStoreClientSettings { + var settings = new KurrentClientSettings { ConnectivitySettings = { DnsGossipSeeds = new[] { allowedNode, notAllowedNode }, Insecure = true @@ -50,7 +51,7 @@ public class NodeSelectorTests { [MemberData(nameof(InvalidStatesCases))] internal void InvalidStatesAreNotConsidered( ClusterMessages.ClusterInfo clusterInfo, - EventStoreClientSettings settings, + KurrentClientSettings settings, DnsEndPoint allowedNode ) { var sut = new NodeSelector(settings); @@ -68,7 +69,7 @@ public void DeadNodesAreNotConsidered() { var notAllowedNodeId = Uuid.NewUuid(); var notAllowedNode = new DnsEndPoint(notAllowedNodeId.ToString(), 2114); - var settings = new EventStoreClientSettings { + var settings = new KurrentClientSettings { ConnectivitySettings = { DnsGossipSeeds = new[] { allowedNode, notAllowedNode }, Insecure = true @@ -96,7 +97,7 @@ public void DeadNodesAreNotConsidered() { [InlineData(NodePreference.ReadOnlyReplica, "readOnlyReplica")] [InlineData(NodePreference.Random, "any")] public void CanPrefer(NodePreference nodePreference, string expectedHost) { - var settings = new EventStoreClientSettings { + var settings = new KurrentClientSettings { ConnectivitySettings = { NodePreference = nodePreference } diff --git a/test/EventStore.Client.Tests/Operations/AuthenticationTests.cs b/test/Kurrent.Client.Tests/Operations/AuthenticationTests.cs similarity index 92% rename from test/EventStore.Client.Tests/Operations/AuthenticationTests.cs rename to test/Kurrent.Client.Tests/Operations/AuthenticationTests.cs index b05bd46ba..61d31701a 100644 --- a/test/EventStore.Client.Tests/Operations/AuthenticationTests.cs +++ b/test/Kurrent.Client.Tests/Operations/AuthenticationTests.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; public class AuthenticationTests(ITestOutputHelper output, AuthenticationTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { public enum CredentialsCase { None, TestUser, RootUser } public static IEnumerable InvalidAuthenticationCases() { @@ -40,7 +42,7 @@ async Task ExecuteTest(int caseNr, CredentialsCase defaultCredentials, Credentia settings.DefaultCredentials = defaultUserCredentials; settings.ConnectionName = $"Authentication case #{caseNr} {defaultCredentials}"; - await using var operations = new EventStoreOperationsClient(settings); + await using var operations = new KurrentOperationsClient(settings); if (shouldThrow) await operations diff --git a/test/EventStore.Client.Tests/Operations/MergeIndexTests.cs b/test/Kurrent.Client.Tests/Operations/MergeIndexTests.cs similarity index 80% rename from test/EventStore.Client.Tests/Operations/MergeIndexTests.cs rename to test/Kurrent.Client.Tests/Operations/MergeIndexTests.cs index 9da4fc434..629924a45 100644 --- a/test/EventStore.Client.Tests/Operations/MergeIndexTests.cs +++ b/test/Kurrent.Client.Tests/Operations/MergeIndexTests.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; public class MergeIndexTests(ITestOutputHelper output, MergeIndexTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task merge_indexes_does_not_throw() => await Fixture.Operations diff --git a/test/EventStore.Client.Tests/Operations/ResignNodeTests.cs b/test/Kurrent.Client.Tests/Operations/ResignNodeTests.cs similarity index 82% rename from test/EventStore.Client.Tests/Operations/ResignNodeTests.cs rename to test/Kurrent.Client.Tests/Operations/ResignNodeTests.cs index b707a41b5..4961873ad 100644 --- a/test/EventStore.Client.Tests/Operations/ResignNodeTests.cs +++ b/test/Kurrent.Client.Tests/Operations/ResignNodeTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.Operations; +namespace Kurrent.Client.Tests.Operations; public class ResignNodeTests(ITestOutputHelper output, ResignNodeTests.CustomFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Operations/RestartPersistentSubscriptionsTests.cs b/test/Kurrent.Client.Tests/Operations/RestartPersistentSubscriptionsTests.cs similarity index 55% rename from test/EventStore.Client.Tests/Operations/RestartPersistentSubscriptionsTests.cs rename to test/Kurrent.Client.Tests/Operations/RestartPersistentSubscriptionsTests.cs index b14242f80..a0683c6b4 100644 --- a/test/EventStore.Client.Tests/Operations/RestartPersistentSubscriptionsTests.cs +++ b/test/Kurrent.Client.Tests/Operations/RestartPersistentSubscriptionsTests.cs @@ -1,9 +1,10 @@ -using EventStore.Client.Tests.TestNode; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests.Operations; +namespace Kurrent.Client.Tests.Operations; -public class RestartPersistentSubscriptionsTests(ITestOutputHelper output, RestartPersistentSubscriptionsTests.NoDefaultCredentialsFixture fixture) - : KurrentTemporaryTests(output, fixture) { +public class RestartPersistentSubscriptionsTests(ITestOutputHelper output, RestartPersistentSubscriptionsTests.CustomFixture fixture) + : KurrentTemporaryTests(output, fixture) { [RetryFact] public async Task restart_persistent_subscriptions_does_not_throw() => await Fixture.Operations @@ -16,5 +17,5 @@ await Fixture.Operations .RestartPersistentSubscriptions() .ShouldThrowAsync(); - public class NoDefaultCredentialsFixture() : KurrentTemporaryFixture(x => x.WithoutDefaultCredentials()); + public class CustomFixture() : KurrentTemporaryFixture(x => x.WithoutDefaultCredentials()); } diff --git a/test/EventStore.Client.Tests/Operations/ScavengeTests.cs b/test/Kurrent.Client.Tests/Operations/ScavengeTests.cs similarity index 94% rename from test/EventStore.Client.Tests/Operations/ScavengeTests.cs rename to test/Kurrent.Client.Tests/Operations/ScavengeTests.cs index 39090938b..7dc6912e4 100644 --- a/test/EventStore.Client.Tests/Operations/ScavengeTests.cs +++ b/test/Kurrent.Client.Tests/Operations/ScavengeTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class ScavengeTests(ITestOutputHelper output, ScavengeTests.CustomFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Operations/ShutdownNodeAuthenticationTests.cs b/test/Kurrent.Client.Tests/Operations/ShutdownNodeAuthenticationTests.cs similarity index 79% rename from test/EventStore.Client.Tests/Operations/ShutdownNodeAuthenticationTests.cs rename to test/Kurrent.Client.Tests/Operations/ShutdownNodeAuthenticationTests.cs index da575c581..a1294f878 100644 --- a/test/EventStore.Client.Tests/Operations/ShutdownNodeAuthenticationTests.cs +++ b/test/Kurrent.Client.Tests/Operations/ShutdownNodeAuthenticationTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class ShutdownNodeAuthenticationTests(ITestOutputHelper output, ShutdownNodeAuthenticationTests.CustomFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Operations/ShutdownNodeTests.cs b/test/Kurrent.Client.Tests/Operations/ShutdownNodeTests.cs similarity index 80% rename from test/EventStore.Client.Tests/Operations/ShutdownNodeTests.cs rename to test/Kurrent.Client.Tests/Operations/ShutdownNodeTests.cs index 8680865d2..e668dfe72 100644 --- a/test/EventStore.Client.Tests/Operations/ShutdownNodeTests.cs +++ b/test/Kurrent.Client.Tests/Operations/ShutdownNodeTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.Operations; +namespace Kurrent.Client.Tests.Operations; public class ShutdownNodeTests(ITestOutputHelper output, ShutdownNodeTests.NoDefaultCredentialsFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/FilterTestCases.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/FilterTestCases.cs similarity index 94% rename from test/EventStore.Client.Tests/PersistentSubscriptions/FilterTestCases.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/FilterTestCases.cs index e7a2355e8..5dc4c098b 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/FilterTestCases.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/FilterTestCases.cs @@ -1,6 +1,7 @@ using System.Reflection; +using EventStore.Client; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public static class Filters { const string StreamNamePrefix = nameof(StreamNamePrefix); diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllConnectWithoutReadPermissionsTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllConnectWithoutReadPermissionsTests.cs similarity index 86% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllConnectWithoutReadPermissionsTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllConnectWithoutReadPermissionsTests.cs index d31a6d3f3..7a3a1e0e2 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllConnectWithoutReadPermissionsTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllConnectWithoutReadPermissionsTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllConnectWithoutReadPermissionsTests(ITestOutputHelper output, KurrentTemporaryFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllFilterTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllFilterTests.cs similarity index 96% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllFilterTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllFilterTests.cs index b4607e03f..52fa03ef1 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllFilterTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllFilterTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllFilterTests(ITestOutputHelper output, KurrentTemporaryFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllGetInfoTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllGetInfoTests.cs similarity index 95% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllGetInfoTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllGetInfoTests.cs index c9f11f171..0dddd0b8b 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllGetInfoTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllGetInfoTests.cs @@ -1,9 +1,9 @@ // ReSharper disable InconsistentNaming -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllGetInfoTests(SubscribeToAllGetInfoTests.CustomFixture fixture) : IClassFixture { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllListWithIncorrectCredentialsTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllListWithIncorrectCredentialsTests.cs similarity index 93% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllListWithIncorrectCredentialsTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllListWithIncorrectCredentialsTests.cs index 6df8b9d7b..47fd98fa9 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllListWithIncorrectCredentialsTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllListWithIncorrectCredentialsTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllListWithIncorrectCredentialsTests(ITestOutputHelper output, SubscribeToAllListWithIncorrectCredentialsTests.CustomFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllNoDefaultCredentialsTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllNoDefaultCredentialsTests.cs similarity index 93% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllNoDefaultCredentialsTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllNoDefaultCredentialsTests.cs index d7143d1d2..8f2ee60cd 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllNoDefaultCredentialsTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllNoDefaultCredentialsTests.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests.PersistentSubscriptions; +using EventStore.Client; + +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllNoDefaultCredentialsTests(ITestOutputHelper output, SubscribeToAllNoDefaultCredentialsTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task connect_to_existing_without_permissions() { var group = Fixture.GetGroupName(); diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReplayParkedTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReplayParkedTests.cs similarity index 93% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReplayParkedTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReplayParkedTests.cs index ca608d092..802501317 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReplayParkedTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReplayParkedTests.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests.PersistentSubscriptions; +using EventStore.Client; + +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllReplayParkedTests(ITestOutputHelper output, SubscribeToAllReplayParkedTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task does_not_throw() { var group = Fixture.GetGroupName(); diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllResultWithNormalUserCredentialsTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllResultWithNormalUserCredentialsTests.cs similarity index 88% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllResultWithNormalUserCredentialsTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllResultWithNormalUserCredentialsTests.cs index 906bbde8f..10a52d40b 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllResultWithNormalUserCredentialsTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllResultWithNormalUserCredentialsTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllResultWithNormalUserCredentialsTests(ITestOutputHelper output, KurrentTemporaryFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsAllSubscriptions.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsAllSubscriptions.cs similarity index 90% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsAllSubscriptions.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsAllSubscriptions.cs index e7b542da9..2fcc75d6f 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsAllSubscriptions.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsAllSubscriptions.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllReturnsAllSubscriptions(ITestOutputHelper output, SubscribeToAllReturnsAllSubscriptions.CustomFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsSubscriptionsToAllStreamTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsSubscriptionsToAllStreamTests.cs similarity index 88% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsSubscriptionsToAllStreamTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsSubscriptionsToAllStreamTests.cs index 9a5e9c2a1..4ab3bd508 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsSubscriptionsToAllStreamTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllReturnsSubscriptionsToAllStreamTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllReturnsSubscriptionsToAllStreamTests(ITestOutputHelper output, KurrentTemporaryFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllTests.cs similarity index 99% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllTests.cs index 1b6e55834..f8fc927b2 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllTests.cs @@ -1,10 +1,11 @@ using System.Text; +using EventStore.Client; using Grpc.Core; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllTests(ITestOutputHelper output, KurrentPermanentFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task can_create_duplicate_name_on_different_streams() { // Arrange @@ -100,7 +101,7 @@ await Fixture.Streams.AppendToStreamAsync( var subscription = Fixture.Subscriptions.SubscribeToAll(group, userCredentials: TestCredentials.Root); await Assert.ThrowsAsync( - () => subscription!.Messages + () => subscription.Messages .OfType() .Where(e => !SystemStreams.IsSystemStream(e.ResolvedEvent.OriginalStreamId)) .AnyAsync() diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllUpdateExistingWithCheckpointTest.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllUpdateExistingWithCheckpointTest.cs similarity index 94% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllUpdateExistingWithCheckpointTest.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllUpdateExistingWithCheckpointTest.cs index 091d55583..54e9f64ff 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllUpdateExistingWithCheckpointTest.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllUpdateExistingWithCheckpointTest.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllUpdateExistingWithCheckpointTest(ITestOutputHelper output, KurrentTemporaryFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllWithoutPSTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllWithoutPSTests.cs similarity index 74% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllWithoutPSTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllWithoutPSTests.cs index 4afb8744e..059c28606 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToAllWithoutPSTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToAllWithoutPSTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToAllWithoutPsTests(ITestOutputHelper output, KurrentTemporaryFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamGetInfoTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamGetInfoTests.cs similarity index 97% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamGetInfoTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamGetInfoTests.cs index 941ae6a9a..4614c691d 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamGetInfoTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamGetInfoTests.cs @@ -1,9 +1,9 @@ // ReSharper disable InconsistentNaming -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToStreamGetInfoTests(SubscribeToStreamGetInfoTests.NoDefaultCredentialsFixture fixture) : IClassFixture { @@ -151,7 +151,7 @@ public class NoDefaultCredentialsFixture : KurrentTemporaryFixture { public string Group { get; set; } public string Stream { get; set; } - EventStorePersistentSubscriptionsClient.PersistentSubscriptionResult? Subscription; + KurrentPersistentSubscriptionsClient.PersistentSubscriptionResult? Subscription; IAsyncEnumerator? Enumerator; public NoDefaultCredentialsFixture() : base(x => x.WithoutDefaultCredentials()) { diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamListTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamListTests.cs similarity index 81% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamListTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamListTests.cs index bea57c9ab..dd93d9e2d 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamListTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamListTests.cs @@ -1,7 +1,10 @@ -namespace EventStore.Client.Tests.PersistentSubscriptions; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; + +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToStreamListTests(ITestOutputHelper output, SubscribeToStreamListTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentTemporaryTests(output, fixture) { [RetryFact] public async Task throws_with_no_credentials() { var stream = Fixture.GetStreamName(); @@ -40,5 +43,5 @@ await Assert.ThrowsAsync( ); } - public class CustomFixture() : KurrentPermanentFixture(x => x.WithoutDefaultCredentials()); + public class CustomFixture() : KurrentTemporaryFixture(x => x.WithoutDefaultCredentials()); } diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamNoDefaultCredentialsTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamNoDefaultCredentialsTests.cs similarity index 91% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamNoDefaultCredentialsTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamNoDefaultCredentialsTests.cs index 3a473b58e..858140147 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamNoDefaultCredentialsTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamNoDefaultCredentialsTests.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests.PersistentSubscriptions; +using EventStore.Client; + +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToStreamNoDefaultCredentialsTests(ITestOutputHelper output, SubscribeToStreamNoDefaultCredentialsTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task connect_to_existing_without_permissions() { var group = Fixture.GetGroupName(); diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamReplayParkedTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamReplayParkedTests.cs similarity index 92% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamReplayParkedTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamReplayParkedTests.cs index 384cc8dfb..8aab4c46c 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamReplayParkedTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamReplayParkedTests.cs @@ -1,7 +1,9 @@ -namespace EventStore.Client.Tests.PersistentSubscriptions; +using EventStore.Client; + +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToStreamReplayParkedTests(ITestOutputHelper output, SubscribeToStreamReplayParkedTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task does_not_throw() { var stream = Fixture.GetStreamName(); diff --git a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamTests.cs b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamTests.cs similarity index 99% rename from test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamTests.cs rename to test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamTests.cs index f9cdb68d4..d5ddbc82a 100644 --- a/test/EventStore.Client.Tests/PersistentSubscriptions/SubscribeToStreamTests.cs +++ b/test/Kurrent.Client.Tests/PersistentSubscriptions/SubscribeToStreamTests.cs @@ -1,10 +1,11 @@ using System.Text; +using EventStore.Client; using Grpc.Core; -namespace EventStore.Client.Tests.PersistentSubscriptions; +namespace Kurrent.Client.Tests.PersistentSubscriptions; public class SubscribeToStreamTests(ITestOutputHelper output, KurrentPermanentFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task can_create_duplicate_name_on_different_streams() { var stream = Fixture.GetStreamName(); diff --git a/test/EventStore.Client.Tests/PositionTests.cs b/test/Kurrent.Client.Tests/PositionTests.cs similarity index 97% rename from test/EventStore.Client.Tests/PositionTests.cs rename to test/Kurrent.Client.Tests/PositionTests.cs index b9657a2c5..92a478008 100644 --- a/test/EventStore.Client.Tests/PositionTests.cs +++ b/test/Kurrent.Client.Tests/PositionTests.cs @@ -1,6 +1,7 @@ using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class PositionTests : ValueObjectTests { public PositionTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/ListProjectionTests.cs b/test/Kurrent.Client.Tests/Projections/ListAllProjectionsTests.cs similarity index 62% rename from test/EventStore.Client.Tests/ListProjectionTests.cs rename to test/Kurrent.Client.Tests/Projections/ListAllProjectionsTests.cs index d5d673657..127552eab 100644 --- a/test/EventStore.Client.Tests/ListProjectionTests.cs +++ b/test/Kurrent.Client.Tests/Projections/ListAllProjectionsTests.cs @@ -1,19 +1,11 @@ // ReSharper disable InconsistentNaming -using EventStore.Client.Tests.TestNode; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests; - -public class ListProjectionTests(ITestOutputHelper output, ListProjectionTests.CustomFixture fixture) - : KurrentTemporaryTests(output, fixture) { - [Fact] - public async Task list_all_projections() { - var result = await Fixture.Projections.ListAllAsync(userCredentials: TestCredentials.Root) - .ToArrayAsync(); - - Assert.Equal(result.Select(x => x.Name).OrderBy(x => x), Names.OrderBy(x => x)); - } +namespace Kurrent.Client.Tests; +public class ListAllProjectionsTests(ITestOutputHelper output, ListAllProjectionsTests.CustomFixture fixture) + : KurrentTemporaryTests(output, fixture) { [Fact] public async Task list_continuous_projections() { var name = Fixture.GetProjectionName(); diff --git a/test/Kurrent.Client.Tests/Projections/ListContinuousProjectionsTests.cs b/test/Kurrent.Client.Tests/Projections/ListContinuousProjectionsTests.cs new file mode 100644 index 000000000..1f3bbd76c --- /dev/null +++ b/test/Kurrent.Client.Tests/Projections/ListContinuousProjectionsTests.cs @@ -0,0 +1,35 @@ +// ReSharper disable InconsistentNaming + +using Kurrent.Client.Tests.TestNode; + +namespace Kurrent.Client.Tests; + +public class ListContinuousProjectionsTests(ITestOutputHelper output, ListContinuousProjectionsTests.CustomFixture fixture) + : KurrentTemporaryTests(output, fixture) { + [Fact] + public async Task list_continuous_projections() { + var name = Fixture.GetProjectionName(); + + await Fixture.Projections.CreateContinuousAsync( + name, + "fromAll().when({$init: function (state, ev) {return {};}});", + userCredentials: TestCredentials.Root + ); + + var result = await Fixture.Projections.ListContinuousAsync(userCredentials: TestCredentials.Root) + .ToArrayAsync(); + + Assert.Equal( + result.Select(x => x.Name).OrderBy(x => x), + Names.Concat([name]).OrderBy(x => x) + ); + + Assert.True(result.All(x => x.Mode == "Continuous")); + } + + static readonly string[] Names = ["$streams", "$stream_by_category", "$by_category", "$by_event_type", "$by_correlation_id"]; + + public class CustomFixture : KurrentTemporaryFixture { + public CustomFixture() : base(x => x.RunProjections()) { } + } +} diff --git a/test/Kurrent.Client.Tests/Projections/ListOneTimeProjectionsTests.cs b/test/Kurrent.Client.Tests/Projections/ListOneTimeProjectionsTests.cs new file mode 100644 index 000000000..b2b0d3899 --- /dev/null +++ b/test/Kurrent.Client.Tests/Projections/ListOneTimeProjectionsTests.cs @@ -0,0 +1,21 @@ +using Kurrent.Client.Tests.TestNode; + +namespace Kurrent.Client.Tests.Projections; + +public class ListOneTimeProjectionsTests(ITestOutputHelper output, ListOneTimeProjectionsTests.CustomFixture fixture) + : KurrentTemporaryTests(output, fixture) { + [Fact] + public async Task list_one_time_projections() { + await Fixture.Projections.CreateOneTimeAsync("fromAll().when({$init: function (state, ev) {return {};}});", userCredentials: TestCredentials.Root); + + var result = await Fixture.Projections.ListOneTimeAsync(userCredentials: TestCredentials.Root) + .ToArrayAsync(); + + var details = Assert.Single(result); + Assert.Equal("OneTime", details.Mode); + } + + public class CustomFixture : KurrentTemporaryFixture { + public CustomFixture() : base(x => x.RunProjections()) { } + } +} diff --git a/test/EventStore.Client.Tests/ProjectionManagementTests.cs b/test/Kurrent.Client.Tests/Projections/ProjectionManagementTests.cs similarity index 86% rename from test/EventStore.Client.Tests/ProjectionManagementTests.cs rename to test/Kurrent.Client.Tests/Projections/ProjectionManagementTests.cs index e119ff3cd..83b1a35cc 100644 --- a/test/EventStore.Client.Tests/ProjectionManagementTests.cs +++ b/test/Kurrent.Client.Tests/Projections/ProjectionManagementTests.cs @@ -1,10 +1,10 @@ // ReSharper disable InconsistentNaming // ReSharper disable ClassNeverInstantiated.Local -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class ProjectionManagementTests(ITestOutputHelper output, ProjectionManagementTests.CustomFixture fixture) : KurrentTemporaryTests(output, fixture) { @@ -172,27 +172,6 @@ await Fixture.Projections.UpdateAsync( ); } - [Fact] - public async Task list_one_time_projections() { - await Fixture.Projections.CreateOneTimeAsync("fromAll().when({$init: function (state, ev) {return {};}});", userCredentials: TestCredentials.Root); - - var result = await Fixture.Projections.ListOneTimeAsync(userCredentials: TestCredentials.Root) - .ToArrayAsync(); - - var details = Assert.Single(result); - Assert.Equal("OneTime", details.Mode); - } - - [Fact] - public async Task reset_projection() { - var name = Names.First(); - await Fixture.Projections.ResetAsync(name, userCredentials: TestCredentials.Root); - var result = await Fixture.Projections.GetStatusAsync(name, userCredentials: TestCredentials.Root); - - Assert.NotNull(result); - Assert.Equal("Running", result.Status); - } - static readonly string[] Names = ["$streams", "$stream_by_category", "$by_category", "$by_event_type", "$by_correlation_id"]; record Result { diff --git a/test/Kurrent.Client.Tests/Projections/ResetProjectionTests.cs b/test/Kurrent.Client.Tests/Projections/ResetProjectionTests.cs new file mode 100644 index 000000000..44e596667 --- /dev/null +++ b/test/Kurrent.Client.Tests/Projections/ResetProjectionTests.cs @@ -0,0 +1,22 @@ +using Kurrent.Client.Tests.TestNode; + +namespace Kurrent.Client.Tests.Projections; + +public class ResetProjectionTests(ITestOutputHelper output, ResetProjectionTests.CustomFixture fixture) + : KurrentTemporaryTests(output, fixture) { + [Fact] + public async Task reset_projection() { + var name = Names.First(); + await Fixture.Projections.ResetAsync(name, userCredentials: TestCredentials.Root); + var result = await Fixture.Projections.GetStatusAsync(name, userCredentials: TestCredentials.Root); + + Assert.NotNull(result); + Assert.Equal("Running", result.Status); + } + + static readonly string[] Names = ["$streams", "$stream_by_category", "$by_category", "$by_event_type", "$by_correlation_id"]; + + public class CustomFixture : KurrentTemporaryFixture { + public CustomFixture() : base(x => x.RunProjections()) { } + } +} diff --git a/test/EventStore.Client.Tests/RegularFilterExpressionTests.cs b/test/Kurrent.Client.Tests/RegularFilterExpressionTests.cs similarity index 87% rename from test/EventStore.Client.Tests/RegularFilterExpressionTests.cs rename to test/Kurrent.Client.Tests/RegularFilterExpressionTests.cs index 67bd40039..de0a4e7b9 100644 --- a/test/EventStore.Client.Tests/RegularFilterExpressionTests.cs +++ b/test/Kurrent.Client.Tests/RegularFilterExpressionTests.cs @@ -1,7 +1,8 @@ using System.Text.RegularExpressions; using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class RegularFilterExpressionTests : ValueObjectTests { public RegularFilterExpressionTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/Security/AllStreamWithNoAclSecurityTests.cs b/test/Kurrent.Client.Tests/Security/AllStreamWithNoAclSecurityTests.cs similarity index 96% rename from test/EventStore.Client.Tests/Security/AllStreamWithNoAclSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/AllStreamWithNoAclSecurityTests.cs index b404c7357..1e6da34e4 100644 --- a/test/EventStore.Client.Tests/Security/AllStreamWithNoAclSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/AllStreamWithNoAclSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class AllStreamWithNoAclSecurityTests(ITestOutputHelper output, AllStreamWithNoAclSecurityTests.CustomFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/DeleteStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/DeleteStreamSecurityTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Security/DeleteStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/DeleteStreamSecurityTests.cs index 7e661bf9f..feea4452a 100644 --- a/test/EventStore.Client.Tests/Security/DeleteStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/DeleteStreamSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class DeleteStreamSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/MultipleRoleSecurityTests.cs b/test/Kurrent.Client.Tests/Security/MultipleRoleSecurityTests.cs similarity index 93% rename from test/EventStore.Client.Tests/Security/MultipleRoleSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/MultipleRoleSecurityTests.cs index 3a5a0c78a..fd36ae2d3 100644 --- a/test/EventStore.Client.Tests/Security/MultipleRoleSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/MultipleRoleSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class MultipleRoleSecurityTests(ITestOutputHelper output, MultipleRoleSecurityTests.CustomFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/OverridenSystemStreamSecurityForAllTests.cs b/test/Kurrent.Client.Tests/Security/OverridenSystemStreamSecurityForAllTests.cs similarity index 95% rename from test/EventStore.Client.Tests/Security/OverridenSystemStreamSecurityForAllTests.cs rename to test/Kurrent.Client.Tests/Security/OverridenSystemStreamSecurityForAllTests.cs index dd4057df0..14f3e1b93 100644 --- a/test/EventStore.Client.Tests/Security/OverridenSystemStreamSecurityForAllTests.cs +++ b/test/Kurrent.Client.Tests/Security/OverridenSystemStreamSecurityForAllTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class OverridenSystemStreamSecurityForAllTests(ITestOutputHelper output, OverridenSystemStreamSecurityForAllTests.CustomFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/OverridenSystemStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/OverridenSystemStreamSecurityTests.cs similarity index 97% rename from test/EventStore.Client.Tests/Security/OverridenSystemStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/OverridenSystemStreamSecurityTests.cs index deedbb33d..af9e4ddd8 100644 --- a/test/EventStore.Client.Tests/Security/OverridenSystemStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/OverridenSystemStreamSecurityTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class OverridenSystemStreamSecurityTests(ITestOutputHelper output, OverridenSystemStreamSecurityTests.CustomFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/OverridenUserStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/OverridenUserStreamSecurityTests.cs similarity index 97% rename from test/EventStore.Client.Tests/Security/OverridenUserStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/OverridenUserStreamSecurityTests.cs index f5da17028..78cbe52ec 100644 --- a/test/EventStore.Client.Tests/Security/OverridenUserStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/OverridenUserStreamSecurityTests.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class OverridenUserStreamSecurityTests(ITestOutputHelper output, OverridenUserStreamSecurityTests.CustomFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/ReadAllSecurityTests.cs b/test/Kurrent.Client.Tests/Security/ReadAllSecurityTests.cs similarity index 92% rename from test/EventStore.Client.Tests/Security/ReadAllSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/ReadAllSecurityTests.cs index b1a88b7a4..e87c12762 100644 --- a/test/EventStore.Client.Tests/Security/ReadAllSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/ReadAllSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class ReadAllSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/ReadStreamMetaSecurityTests.cs b/test/Kurrent.Client.Tests/Security/ReadStreamMetaSecurityTests.cs similarity index 96% rename from test/EventStore.Client.Tests/Security/ReadStreamMetaSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/ReadStreamMetaSecurityTests.cs index b3c966977..8d6e56c29 100644 --- a/test/EventStore.Client.Tests/Security/ReadStreamMetaSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/ReadStreamMetaSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class ReadStreamMetaSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/ReadStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/ReadStreamSecurityTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Security/ReadStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/ReadStreamSecurityTests.cs index a26d7e797..9422ff671 100644 --- a/test/EventStore.Client.Tests/Security/ReadStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/ReadStreamSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class ReadStreamSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/SecurityFixture.cs b/test/Kurrent.Client.Tests/Security/SecurityFixture.cs similarity index 99% rename from test/EventStore.Client.Tests/Security/SecurityFixture.cs rename to test/Kurrent.Client.Tests/Security/SecurityFixture.cs index 43edb97b6..366284a11 100644 --- a/test/EventStore.Client.Tests/Security/SecurityFixture.cs +++ b/test/Kurrent.Client.Tests/Security/SecurityFixture.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; using EventStore.Client; -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using Kurrent.Client; +using Kurrent.Client.Tests.TestNode; public class SecurityFixture : KurrentTemporaryFixture { public const string NoAclStream = nameof(NoAclStream); diff --git a/test/EventStore.Client.Tests/Security/StreamSecurityInheritanceTests.cs b/test/Kurrent.Client.Tests/Security/StreamSecurityInheritanceTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Security/StreamSecurityInheritanceTests.cs rename to test/Kurrent.Client.Tests/Security/StreamSecurityInheritanceTests.cs index 247da1a3e..dbe25c65e 100644 --- a/test/EventStore.Client.Tests/Security/StreamSecurityInheritanceTests.cs +++ b/test/Kurrent.Client.Tests/Security/StreamSecurityInheritanceTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class StreamSecurityInheritanceTests(ITestOutputHelper output, StreamSecurityInheritanceTests.CustomFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/SubscribeToAllSecurityTests.cs b/test/Kurrent.Client.Tests/Security/SubscribeToAllSecurityTests.cs similarity index 89% rename from test/EventStore.Client.Tests/Security/SubscribeToAllSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/SubscribeToAllSecurityTests.cs index c42e552f0..62f35497a 100644 --- a/test/EventStore.Client.Tests/Security/SubscribeToAllSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/SubscribeToAllSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class SubscribeToAllSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/SubscribeToStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/SubscribeToStreamSecurityTests.cs similarity index 96% rename from test/EventStore.Client.Tests/Security/SubscribeToStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/SubscribeToStreamSecurityTests.cs index 6e34a6144..8d9625990 100644 --- a/test/EventStore.Client.Tests/Security/SubscribeToStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/SubscribeToStreamSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class SubscribeToStreamSecurityTests(ITestOutputHelper output, SecurityFixture fixture) diff --git a/test/EventStore.Client.Tests/Security/SystemStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/SystemStreamSecurityTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Security/SystemStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/SystemStreamSecurityTests.cs index f5ed68732..4ed213611 100644 --- a/test/EventStore.Client.Tests/Security/SystemStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/SystemStreamSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class SystemStreamSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/WriteStreamMetaSecurityTests.cs b/test/Kurrent.Client.Tests/Security/WriteStreamMetaSecurityTests.cs similarity index 96% rename from test/EventStore.Client.Tests/Security/WriteStreamMetaSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/WriteStreamMetaSecurityTests.cs index d2ace8af4..a2346b124 100644 --- a/test/EventStore.Client.Tests/Security/WriteStreamMetaSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/WriteStreamMetaSecurityTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class WriteStreamMetaSecurityTests(ITestOutputHelper output, SecurityFixture fixture) : KurrentTemporaryTests(output, fixture) { diff --git a/test/EventStore.Client.Tests/Security/WriteStreamSecurityTests.cs b/test/Kurrent.Client.Tests/Security/WriteStreamSecurityTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Security/WriteStreamSecurityTests.cs rename to test/Kurrent.Client.Tests/Security/WriteStreamSecurityTests.cs index aec66866a..0bb37a94d 100644 --- a/test/EventStore.Client.Tests/Security/WriteStreamSecurityTests.cs +++ b/test/Kurrent.Client.Tests/Security/WriteStreamSecurityTests.cs @@ -1,4 +1,6 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; [Trait("Category", "Security")] public class WriteStreamSecurityTests : IClassFixture { diff --git a/test/EventStore.Client.Tests/SharingProviderTests.cs b/test/Kurrent.Client.Tests/SharingProviderTests.cs similarity index 97% rename from test/EventStore.Client.Tests/SharingProviderTests.cs rename to test/Kurrent.Client.Tests/SharingProviderTests.cs index efebe5b09..fc1f31abc 100644 --- a/test/EventStore.Client.Tests/SharingProviderTests.cs +++ b/test/Kurrent.Client.Tests/SharingProviderTests.cs @@ -1,6 +1,8 @@ -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously +using EventStore.Client; -namespace EventStore.Client.Tests; +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + +namespace Kurrent.Client.Tests; public class SharingProviderTests { [RetryFact] diff --git a/test/EventStore.Client.Tests/StreamPositionTests.cs b/test/Kurrent.Client.Tests/StreamPositionTests.cs similarity index 98% rename from test/EventStore.Client.Tests/StreamPositionTests.cs rename to test/Kurrent.Client.Tests/StreamPositionTests.cs index d70475dd0..591ea617e 100644 --- a/test/EventStore.Client.Tests/StreamPositionTests.cs +++ b/test/Kurrent.Client.Tests/StreamPositionTests.cs @@ -1,6 +1,7 @@ using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class StreamPositionTests : ValueObjectTests { public StreamPositionTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/StreamRevisionTests.cs b/test/Kurrent.Client.Tests/StreamRevisionTests.cs similarity index 98% rename from test/EventStore.Client.Tests/StreamRevisionTests.cs rename to test/Kurrent.Client.Tests/StreamRevisionTests.cs index ddc479251..4b77f81d0 100644 --- a/test/EventStore.Client.Tests/StreamRevisionTests.cs +++ b/test/Kurrent.Client.Tests/StreamRevisionTests.cs @@ -1,6 +1,7 @@ using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class StreamRevisionTests : ValueObjectTests { public StreamRevisionTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/StreamStateTests.cs b/test/Kurrent.Client.Tests/StreamStateTests.cs similarity index 96% rename from test/EventStore.Client.Tests/StreamStateTests.cs rename to test/Kurrent.Client.Tests/StreamStateTests.cs index 7bd086099..b9c68df38 100644 --- a/test/EventStore.Client.Tests/StreamStateTests.cs +++ b/test/Kurrent.Client.Tests/StreamStateTests.cs @@ -1,7 +1,8 @@ using System.Reflection; using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class StreamStateTests : ValueObjectTests { public StreamStateTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/Streams/AppendTests.cs b/test/Kurrent.Client.Tests/Streams/AppendTests.cs similarity index 99% rename from test/EventStore.Client.Tests/Streams/AppendTests.cs rename to test/Kurrent.Client.Tests/Streams/AppendTests.cs index 3b2792b8f..0dab95225 100644 --- a/test/EventStore.Client.Tests/Streams/AppendTests.cs +++ b/test/Kurrent.Client.Tests/Streams/AppendTests.cs @@ -1,11 +1,12 @@ +using EventStore.Client; using Grpc.Core; using Humanizer; -namespace EventStore.Client.Tests.Streams; +namespace Kurrent.Client.Tests.Streams; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Append")] -public class AppendTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : EventStorePermanentTests(output, fixture) { +public class AppendTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : KurrentPermanentTests(output, fixture) { [Theory, ExpectedVersionCreateStreamTestCases] public async Task appending_zero_events(StreamState expectedStreamState) { var stream = $"{Fixture.GetStreamName()}_{expectedStreamState}"; diff --git a/test/EventStore.Client.Tests/Streams/Bugs/Obsolete/Issue104.cs b/test/Kurrent.Client.Tests/Streams/Bugs/Obsolete/Issue104.cs similarity index 91% rename from test/EventStore.Client.Tests/Streams/Bugs/Obsolete/Issue104.cs rename to test/Kurrent.Client.Tests/Streams/Bugs/Obsolete/Issue104.cs index 47386e105..9eb0124b4 100644 --- a/test/EventStore.Client.Tests/Streams/Bugs/Obsolete/Issue104.cs +++ b/test/Kurrent.Client.Tests/Streams/Bugs/Obsolete/Issue104.cs @@ -1,8 +1,10 @@ -namespace EventStore.Client.Tests.Bugs.Obsolete; +using EventStore.Client; + +namespace Kurrent.Client.Tests.Bugs.Obsolete; [Trait("Category", "Bug")] [Obsolete("Tests will be removed in future release when older subscriptions APIs are removed from the client")] -public class Issue104(ITestOutputHelper output, KurrentPermanentFixture fixture) : EventStorePermanentTests(output, fixture) { +public class Issue104(ITestOutputHelper output, KurrentPermanentFixture fixture) : KurrentPermanentTests(output, fixture) { [Fact] public async Task subscription_does_not_send_checkpoint_reached_after_disposal() { var streamName = Fixture.GetStreamName(); diff --git a/test/EventStore.Client.Tests/Streams/Bugs/Obsolete/Issue2544.cs b/test/Kurrent.Client.Tests/Streams/Bugs/Obsolete/Issue2544.cs similarity index 98% rename from test/EventStore.Client.Tests/Streams/Bugs/Obsolete/Issue2544.cs rename to test/Kurrent.Client.Tests/Streams/Bugs/Obsolete/Issue2544.cs index e2353090e..027a70500 100644 --- a/test/EventStore.Client.Tests/Streams/Bugs/Obsolete/Issue2544.cs +++ b/test/Kurrent.Client.Tests/Streams/Bugs/Obsolete/Issue2544.cs @@ -1,6 +1,8 @@ // ReSharper disable InconsistentNaming -namespace EventStore.Client.Tests.Bugs.Obsolete; +using EventStore.Client; + +namespace Kurrent.Client.Tests.Bugs.Obsolete; [Trait("Category", "Bug")] [Obsolete("Tests will be removed in future release when older subscriptions APIs are removed from the client")] diff --git a/test/EventStore.Client.Tests/Streams/DeleteTests.cs b/test/Kurrent.Client.Tests/Streams/DeleteTests.cs similarity index 96% rename from test/EventStore.Client.Tests/Streams/DeleteTests.cs rename to test/Kurrent.Client.Tests/Streams/DeleteTests.cs index 69bca6b56..07b28f098 100644 --- a/test/EventStore.Client.Tests/Streams/DeleteTests.cs +++ b/test/Kurrent.Client.Tests/Streams/DeleteTests.cs @@ -1,10 +1,11 @@ +using EventStore.Client; using Grpc.Core; -namespace EventStore.Client.Tests.Streams; +namespace Kurrent.Client.Tests.Streams; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Delete")] -public class DeleteTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : EventStorePermanentTests(output, fixture) { +public class DeleteTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : KurrentPermanentTests(output, fixture) { [Theory, ExpectedStreamStateCases] public async Task hard_deleting_a_stream_that_does_not_exist_with_expected_version_does_not_throw(StreamState expectedVersion, string name) { var stream = $"{Fixture.GetStreamName()}_{name}"; diff --git a/test/EventStore.Client.Tests/Streams/Read/EventBinaryData.cs b/test/Kurrent.Client.Tests/Streams/Read/EventBinaryData.cs similarity index 95% rename from test/EventStore.Client.Tests/Streams/Read/EventBinaryData.cs rename to test/Kurrent.Client.Tests/Streams/Read/EventBinaryData.cs index 53e221b5d..09f8ce6ae 100644 --- a/test/EventStore.Client.Tests/Streams/Read/EventBinaryData.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/EventBinaryData.cs @@ -1,4 +1,6 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; public readonly record struct EventBinaryData(Uuid Id, byte[] Data, byte[] Metadata) { public bool Equals(EventBinaryData other) => diff --git a/test/EventStore.Client.Tests/Streams/Read/EventDataComparer.cs b/test/Kurrent.Client.Tests/Streams/Read/EventDataComparer.cs similarity index 90% rename from test/EventStore.Client.Tests/Streams/Read/EventDataComparer.cs rename to test/Kurrent.Client.Tests/Streams/Read/EventDataComparer.cs index 9ffb2c2bc..86113e285 100644 --- a/test/EventStore.Client.Tests/Streams/Read/EventDataComparer.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/EventDataComparer.cs @@ -1,4 +1,6 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; static class EventDataComparer { public static bool Equal(EventData expected, EventRecord actual) { diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadAllEventsBackwardTests.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsBackwardTests.cs similarity index 97% rename from test/EventStore.Client.Tests/Streams/Read/ReadAllEventsBackwardTests.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsBackwardTests.cs index 1620842f9..3de2fd591 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadAllEventsBackwardTests.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsBackwardTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; using Grpc.Core; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:All")] [Trait("Category", "Operation:Read")] diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadAllEventsFixture.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsFixture.cs similarity index 93% rename from test/EventStore.Client.Tests/Streams/Read/ReadAllEventsFixture.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsFixture.cs index 979bcccc0..a6119c874 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadAllEventsFixture.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsFixture.cs @@ -1,7 +1,7 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:All")] [Trait("Category", "Operation:Read")] diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadAllEventsForwardTests.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsForwardTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Streams/Read/ReadAllEventsForwardTests.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsForwardTests.cs index fc927ef11..a8086faf6 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadAllEventsForwardTests.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadAllEventsForwardTests.cs @@ -1,7 +1,8 @@ using System.Text; -using EventStore.Client.Tests.TestNode; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:All")] [Trait("Category", "Operation:Read")] diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs index 01be08eb7..190a33e11 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; using Grpc.Core; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Read")] diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadStreamEventsLinkedToDeletedStreamTests.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamEventsLinkedToDeletedStreamTests.cs similarity index 95% rename from test/EventStore.Client.Tests/Streams/Read/ReadStreamEventsLinkedToDeletedStreamTests.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadStreamEventsLinkedToDeletedStreamTests.cs index a96e82ab5..c5c151868 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadStreamEventsLinkedToDeletedStreamTests.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamEventsLinkedToDeletedStreamTests.cs @@ -1,10 +1,11 @@ using System.Text; -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; // ReSharper disable ClassNeverInstantiated.Global -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:Stream")] public abstract class ReadStreamEventsLinkedToDeletedStreamTests(ReadEventsLinkedToDeletedStreamFixture fixture) { diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadStreamForwardTests.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamForwardTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Streams/Read/ReadStreamForwardTests.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadStreamForwardTests.cs index 4100cf010..d9d2a35d3 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadStreamForwardTests.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamForwardTests.cs @@ -1,9 +1,11 @@ -namespace EventStore.Client.Tests; +using EventStore.Client; + +namespace Kurrent.Client.Tests; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Read")] [Trait("Category", "Operation:Read:Forwards")] -public class ReadStreamForwardTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : EventStorePermanentTests(output, fixture) { +public class ReadStreamForwardTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : KurrentPermanentTests(output, fixture) { [Theory] [InlineData(0)] public async Task count_le_equal_zero_throws(long maxCount) { diff --git a/test/EventStore.Client.Tests/Streams/Read/ReadStreamWhenHavingMaxCountSetForStreamTests.cs b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamWhenHavingMaxCountSetForStreamTests.cs similarity index 97% rename from test/EventStore.Client.Tests/Streams/Read/ReadStreamWhenHavingMaxCountSetForStreamTests.cs rename to test/Kurrent.Client.Tests/Streams/Read/ReadStreamWhenHavingMaxCountSetForStreamTests.cs index fbd2696f1..10a3cce28 100644 --- a/test/EventStore.Client.Tests/Streams/Read/ReadStreamWhenHavingMaxCountSetForStreamTests.cs +++ b/test/Kurrent.Client.Tests/Streams/Read/ReadStreamWhenHavingMaxCountSetForStreamTests.cs @@ -1,7 +1,8 @@ -using EventStore.Client.Tests.TestNode; -using EventStore.Client.Tests; +using EventStore.Client; +using Kurrent.Client.Tests.TestNode; +using Kurrent.Client.Tests; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Read")] diff --git a/test/EventStore.Client.Tests/Streams/SoftDeleteTests.cs b/test/Kurrent.Client.Tests/Streams/SoftDeleteTests.cs similarity index 98% rename from test/EventStore.Client.Tests/Streams/SoftDeleteTests.cs rename to test/Kurrent.Client.Tests/Streams/SoftDeleteTests.cs index 7560167b8..84e4b8176 100644 --- a/test/EventStore.Client.Tests/Streams/SoftDeleteTests.cs +++ b/test/Kurrent.Client.Tests/Streams/SoftDeleteTests.cs @@ -1,10 +1,11 @@ using System.Text.Json; +using EventStore.Client; -namespace EventStore.Client.Tests.Streams; +namespace Kurrent.Client.Tests.Streams; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Delete")] -public class SoftDeleteTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : EventStorePermanentTests(output, fixture) { +public class SoftDeleteTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : KurrentPermanentTests(output, fixture) { static JsonDocument CustomMetadata { get; } static SoftDeleteTests() { diff --git a/test/EventStore.Client.Tests/Streams/StreamMetadataTests.cs b/test/Kurrent.Client.Tests/Streams/StreamMetadataTests.cs similarity index 97% rename from test/EventStore.Client.Tests/Streams/StreamMetadataTests.cs rename to test/Kurrent.Client.Tests/Streams/StreamMetadataTests.cs index 5ba987732..23320b6b2 100644 --- a/test/EventStore.Client.Tests/Streams/StreamMetadataTests.cs +++ b/test/Kurrent.Client.Tests/Streams/StreamMetadataTests.cs @@ -1,11 +1,12 @@ using System.Text.Json; +using EventStore.Client; using Grpc.Core; -namespace EventStore.Client.Tests.Streams; +namespace Kurrent.Client.Tests.Streams; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Metadata")] -public class StreamMetadataTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : EventStorePermanentTests(output, fixture) { +public class StreamMetadataTests(ITestOutputHelper output, KurrentPermanentFixture fixture) : KurrentPermanentTests(output, fixture) { [Fact] public async Task getting_for_an_existing_stream_and_no_metadata_exists() { var stream = Fixture.GetStreamName(); diff --git a/test/EventStore.Client.Tests/Streams/SubscribeToStreamTests.cs b/test/Kurrent.Client.Tests/Streams/SubscribeToStreamTests.cs similarity index 97% rename from test/EventStore.Client.Tests/Streams/SubscribeToStreamTests.cs rename to test/Kurrent.Client.Tests/Streams/SubscribeToStreamTests.cs index 00d38e1e5..61ca29ebb 100644 --- a/test/EventStore.Client.Tests/Streams/SubscribeToStreamTests.cs +++ b/test/Kurrent.Client.Tests/Streams/SubscribeToStreamTests.cs @@ -1,9 +1,11 @@ -namespace EventStore.Client.Tests.Streams; +using EventStore.Client; + +namespace Kurrent.Client.Tests.Streams; [Trait("Category", "Subscriptions")] [Trait("Category", "Target:Stream")] public class SubscribeToStreamTests(ITestOutputHelper output, SubscribeToStreamTests.CustomFixture fixture) - : EventStorePermanentTests(output, fixture) { + : KurrentPermanentTests(output, fixture) { [RetryFact] public async Task receives_all_events_from_start() { var streamName = Fixture.GetStreamName(); diff --git a/test/EventStore.Client.Tests/Streams/SubscriptionFilter.cs b/test/Kurrent.Client.Tests/Streams/SubscriptionFilter.cs similarity index 94% rename from test/EventStore.Client.Tests/Streams/SubscriptionFilter.cs rename to test/Kurrent.Client.Tests/Streams/SubscriptionFilter.cs index 937f7d1a2..91621aba9 100644 --- a/test/EventStore.Client.Tests/Streams/SubscriptionFilter.cs +++ b/test/Kurrent.Client.Tests/Streams/SubscriptionFilter.cs @@ -1,5 +1,8 @@ // ReSharper disable InconsistentNaming -namespace EventStore.Client.Tests.Streams; + +using EventStore.Client; + +namespace Kurrent.Client.Tests.Streams; public record SubscriptionFilter(string Name, Func Create, Func PrepareEvent) { public override string ToString() => Name; diff --git a/test/EventStore.Client.Tests/UuidTests.cs b/test/Kurrent.Client.Tests/UuidTests.cs similarity index 96% rename from test/EventStore.Client.Tests/UuidTests.cs rename to test/Kurrent.Client.Tests/UuidTests.cs index 4533e5416..feeadb4e7 100644 --- a/test/EventStore.Client.Tests/UuidTests.cs +++ b/test/Kurrent.Client.Tests/UuidTests.cs @@ -1,6 +1,7 @@ using AutoFixture; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class UuidTests : ValueObjectTests { public UuidTests() : base(new ScenarioFixture()) { } diff --git a/test/EventStore.Client.Tests/ValueObjectTests.cs b/test/Kurrent.Client.Tests/ValueObjectTests.cs similarity index 92% rename from test/EventStore.Client.Tests/ValueObjectTests.cs rename to test/Kurrent.Client.Tests/ValueObjectTests.cs index c70b5f7d6..dc4e9f2b0 100644 --- a/test/EventStore.Client.Tests/ValueObjectTests.cs +++ b/test/Kurrent.Client.Tests/ValueObjectTests.cs @@ -1,6 +1,6 @@ using AutoFixture; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public abstract class ValueObjectTests { protected readonly Fixture _fixture; diff --git a/test/EventStore.Client.Tests/X509CertificatesTests.cs b/test/Kurrent.Client.Tests/X509CertificatesTests.cs similarity index 95% rename from test/EventStore.Client.Tests/X509CertificatesTests.cs rename to test/Kurrent.Client.Tests/X509CertificatesTests.cs index 5d70fdb89..71d4bc752 100644 --- a/test/EventStore.Client.Tests/X509CertificatesTests.cs +++ b/test/Kurrent.Client.Tests/X509CertificatesTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography.X509Certificates; +using EventStore.Client; -namespace EventStore.Client.Tests; +namespace Kurrent.Client.Tests; public class X509CertificatesTests { [RetryFact]