From 55db949e98ee1ac0e2f99b700d33ff1e28f1cdbc Mon Sep 17 00:00:00 2001 From: Joseph Cummings Date: Thu, 25 Apr 2024 17:23:34 +0100 Subject: [PATCH] Add tracing instrumentation of Append & Subscribe operations --- Directory.Build.props | 8 +- EventStore.Client.sln | 7 + README.md | 18 + gencert.ps1 | 2 +- gencert.sh | 2 +- .../secure-with-tls/docker-compose.certs.yml | 2 +- src/Directory.Build.props | 65 +-- .../Shims/TaskCompletionSource.cs | 8 - ...ore.Client.Extensions.OpenTelemetry.csproj | 16 + .../TracerProviderBuilderExtensions.cs | 19 + ...tore.Client.PersistentSubscriptions.csproj | 3 - ...StorePersistentSubscriptionsClient.Read.cs | 303 +++++++----- .../EventStore.Client.Streams.csproj | 3 - .../EventStoreClient.Append.cs | 432 ++++++++++-------- .../EventStoreClient.Metadata.cs | 2 +- .../EventStoreClient.Subscriptions.cs | 233 ++++++---- .../EventStoreClient.cs | 90 ++-- .../StreamSubscription.cs | 69 ++- src/EventStore.Client/ArrayExtensions.cs | 17 - .../Common}/AsyncStreamReaderExtensions.cs | 0 .../Common}/Constants.cs | 11 +- .../Diagnostics/ActivitySourceExtensions.cs | 70 +++ .../ActivityTagsCollectionExtensions.cs | 32 ++ .../Diagnostics/Core/ActivityExtensions.cs | 52 +++ .../Common/Diagnostics/Core/ActivityStatus.cs | 13 + .../Core/ActivityStatusCodeHelper.cs | 24 + .../Core/ActivityTagsCollectionExtensions.cs | 25 + .../Diagnostics/Core/ExceptionExtensions.cs | 25 + .../Core/Telemetry/TelemetryTags.cs | 35 ++ .../Core/Tracing/TracingConstants.cs | 10 + .../Core/Tracing/TracingMetadata.cs | 33 ++ .../Diagnostics/EventMetadataExtensions.cs | 72 +++ .../EventStoreClientDiagnostics.cs | 8 + .../Diagnostics/Telemetry/TelemetryTags.cs | 12 + .../Diagnostics/Tracing/TracingConstants.cs | 10 + .../Common}/EnumerableTaskExtensions.cs | 0 .../Common}/EpochExtensions.cs | 0 .../Common}/EventStoreCallOptions.cs | 0 .../Common}/MetadataExtensions.cs | 0 .../Common}/Shims/Index.cs | 57 +-- .../Common}/Shims/IsExternalInit.cs | 3 +- .../Common}/Shims/Range.cs | 22 +- .../Common/Shims/TaskCompletionSource.cs | 11 + .../Common}/protos/code.proto | 0 .../Common}/protos/gossip.proto | 0 .../Common}/protos/operations.proto | 0 .../protos/persistentsubscriptions.proto | 0 .../Common}/protos/projectionmanagement.proto | 0 .../Common}/protos/serverfeatures.proto | 0 .../Common}/protos/shared.proto | 0 .../Common}/protos/status.proto | 0 .../Common}/protos/streams.proto | 0 .../Common}/protos/usermanagement.proto | 0 .../EventStore.Client.csproj | 32 +- .../EventStore.Client.csproj.DotSettings | 3 + src/EventStore.Client/EventStoreClientBase.cs | 68 +-- test/Directory.Build.props | 2 +- ...ubscriptionsTracingInstrumentationTests.cs | 74 +++ .../Append/append_to_stream.cs | 65 +-- .../StreamsTracingInstrumentationTests.cs | 148 ++++++ .../Read/ReadAllEventsFixture.cs | 12 +- .../Read/read_stream_backward.cs | 2 +- .../Read/read_stream_forward.cs | 2 +- .../Subscriptions/subscribe_to_all.cs | 127 +++-- .../Subscriptions/subscribe_to_stream.cs | 37 +- .../Fixtures/Base/EventStoreTestServer.cs | 2 +- .../Fixtures/CertificatesManager.cs | 2 +- .../Fixtures/DiagnosticsFixture.cs | 93 ++++ .../Fixtures/EventStoreFixture.Helpers.cs | 44 +- .../Fixtures/EventStoreFixture.cs | 66 +-- .../docker-compose.certs.yml | 4 +- .../docker-compose.cluster.yml | 2 +- .../docker-compose.yml | 2 +- 73 files changed, 1841 insertions(+), 770 deletions(-) delete mode 100644 src/EventStore.Client.Common/Shims/TaskCompletionSource.cs create mode 100644 src/EventStore.Client.Extensions.OpenTelemetry/EventStore.Client.Extensions.OpenTelemetry.csproj create mode 100644 src/EventStore.Client.Extensions.OpenTelemetry/TracerProviderBuilderExtensions.cs delete mode 100644 src/EventStore.Client/ArrayExtensions.cs rename src/{EventStore.Client.Common => EventStore.Client/Common}/AsyncStreamReaderExtensions.cs (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/Constants.cs (90%) create mode 100644 src/EventStore.Client/Common/Diagnostics/ActivitySourceExtensions.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/ActivityTagsCollectionExtensions.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/ActivityExtensions.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/ActivityStatus.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/ActivityStatusCodeHelper.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/ExceptionExtensions.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingConstants.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingMetadata.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/EventMetadataExtensions.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/EventStoreClientDiagnostics.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Telemetry/TelemetryTags.cs create mode 100644 src/EventStore.Client/Common/Diagnostics/Tracing/TracingConstants.cs rename src/{EventStore.Client.Common => EventStore.Client/Common}/EnumerableTaskExtensions.cs (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/EpochExtensions.cs (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/EventStoreCallOptions.cs (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/MetadataExtensions.cs (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/Shims/Index.cs (79%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/Shims/IsExternalInit.cs (84%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/Shims/Range.cs (91%) create mode 100644 src/EventStore.Client/Common/Shims/TaskCompletionSource.cs rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/code.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/gossip.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/operations.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/persistentsubscriptions.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/projectionmanagement.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/serverfeatures.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/shared.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/status.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/streams.proto (100%) rename src/{EventStore.Client.Common => EventStore.Client/Common}/protos/usermanagement.proto (100%) create mode 100644 src/EventStore.Client/EventStore.Client.csproj.DotSettings create mode 100644 test/EventStore.Client.PersistentSubscriptions.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs create mode 100644 test/EventStore.Client.Streams.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs create mode 100644 test/EventStore.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs diff --git a/Directory.Build.props b/Directory.Build.props index 1d320aee3..dd2dd9339 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,8 +17,8 @@ 2.60.0 - - - - + + + + diff --git a/EventStore.Client.sln b/EventStore.Client.sln index 03ecf4045..72ec92f54 100644 --- a/EventStore.Client.sln +++ b/EventStore.Client.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.UserManag 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}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Extensions.OpenTelemetry", "src\EventStore.Client.Extensions.OpenTelemetry\EventStore.Client.Extensions.OpenTelemetry.csproj", "{3723933C-585A-49BE-98E8-52D3FAD904CE}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Plugins.Tests", "test\EventStore.Client.Plugins.Tests\EventStore.Client.Plugins.Tests.csproj", "{7D929D45-F1D9-462B-BE49-84BEC11D5039}" EndProject Global @@ -96,6 +98,10 @@ Global {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 + {3723933C-585A-49BE-98E8-52D3FAD904CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {3723933C-585A-49BE-98E8-52D3FAD904CE}.Debug|x64.Build.0 = Debug|Any CPU + {3723933C-585A-49BE-98E8-52D3FAD904CE}.Release|x64.ActiveCfg = Release|Any CPU + {3723933C-585A-49BE-98E8-52D3FAD904CE}.Release|x64.Build.0 = Release|Any CPU {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Debug|x64.ActiveCfg = Debug|Any CPU {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Debug|x64.Build.0 = Debug|Any CPU {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Release|x64.ActiveCfg = Release|Any CPU @@ -115,6 +121,7 @@ Global {6CEB731F-72E1-461F-A6B3-54DBF3FD786C} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} {22634CEE-4F7B-4679-A48D-38A2A8580ECA} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} {E326832D-DE52-4DE4-9E54-C800908B75F3} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} + {3723933C-585A-49BE-98E8-52D3FAD904CE} = {EA59C1CB-16DA-4F68-AF8A-642A969B4CF8} {7D929D45-F1D9-462B-BE49-84BEC11D5039} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index efb247e01..7d984a72e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,24 @@ Reference the nuget package(s) for the API that you would like to call [User Management](https://www.nuget.org/packages/EventStore.Client.Grpc.UserManagement) +## Open Telemetry + +Telemetry instrumentation can be enabled by installing the [Open Telemetry Extensions](https://www.nuget.org/packages/EventStore.Client.Extensions.OpenTelemetry) package. + +Once installed you can configure instrumentation using the `AddEventStoreClientInstrumentation` extension method on a `TracerProviderBuilder`. + +```csharp +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + ... + .AddEventStoreClientInstrumentation() + ... + .Build(); +``` + +Tracing is the only telemetry currently exported, specifically for the `Append` and `Subscribe` (Catchup and Persistent) operations. + +For more information about Open Telemetry, refer to the [official documentation](https://opentelemetry.io/docs/what-is-opentelemetry/). + ## Support Information on support and commercial tools such as LDAP authentication can be found here: [Event Store Support](https://eventstore.com/support/). diff --git a/gencert.ps1 b/gencert.ps1 index 34e74a974..f2b5dff13 100644 --- a/gencert.ps1 +++ b/gencert.ps1 @@ -7,7 +7,7 @@ New-Item -ItemType Directory -Path .\certs -Force icacls .\certs /grant:r "$($env:UserName):(OI)(CI)F" # Pull the Docker image -docker pull ghcr.io/eventstore/es-gencert-cli:1.3 +docker pull ghcr.io/eventstore/es-gencert-cli:1.3.0 docker run --rm --volume .\certs:/tmp ghcr.io/eventstore/es-gencert-cli create-ca -out /tmp/ca diff --git a/gencert.sh b/gencert.sh index 7cd69b56a..c9c1878b8 100755 --- a/gencert.sh +++ b/gencert.sh @@ -13,7 +13,7 @@ mkdir -p certs chmod 0755 ./certs -docker pull ghcr.io/eventstore/es-gencert-cli:1.3 +docker pull ghcr.io/eventstore/es-gencert-cli:1.3.0 docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) ghcr.io/eventstore/es-gencert-cli create-ca -out /tmp/ca diff --git a/samples/secure-with-tls/docker-compose.certs.yml b/samples/secure-with-tls/docker-compose.certs.yml index dc7cbd7c2..179fa05c2 100644 --- a/samples/secure-with-tls/docker-compose.certs.yml +++ b/samples/secure-with-tls/docker-compose.certs.yml @@ -16,7 +16,7 @@ services: network_mode: none cert-gen: - image: ghcr.io/eventstore/es-gencert-cli:1.3 + image: eventstore/es-gencert-cli:1.3.0 container_name: cert-gen user: "1000:1000" entrypoint: [ "/bin/sh","-c" ] diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c12d9449c..e6ea54167 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,19 +1,25 @@ - + + EventStore.Client - - $(MSBuildProjectName.Remove(0,18)) - $(ESPackageIdSuffix.ToLower()).proto - ../EventStore.Client.Common/protos/$(ESProto) - EventStore.Client.Grpc.$(ESPackageIdSuffix) - - + - - + + + + + + $(MSBuildProjectName.Remove(0,18)) + EventStore.Client.Grpc.$(PackageIdSuffix) + ../EventStore.Client/Common/protos/$(PackageIdSuffix.ToLower()).proto + + + + + @@ -26,14 +32,13 @@ Event Store Ltd Copyright 2012-2020 Event Store Ltd v - true - - + + - + all @@ -43,22 +48,24 @@ all runtime; build; native; contentfiles; analyzers - + - - - - - + - - <_Parameter1>$(ProjectName).Tests - - - <_Parameter1>$(ProjectName).Tests.Common - - - <_Parameter1>EventStore.Client - + + + + + + + + + + + + + + + diff --git a/src/EventStore.Client.Common/Shims/TaskCompletionSource.cs b/src/EventStore.Client.Common/Shims/TaskCompletionSource.cs deleted file mode 100644 index e7e88a97f..000000000 --- a/src/EventStore.Client.Common/Shims/TaskCompletionSource.cs +++ /dev/null @@ -1,8 +0,0 @@ -#if !NET -namespace System.Threading.Tasks; - -internal class TaskCompletionSource : TaskCompletionSource { - public void SetResult() => base.SetResult(null); - public bool TrySetResult() => base.TrySetResult(null); -} -#endif diff --git a/src/EventStore.Client.Extensions.OpenTelemetry/EventStore.Client.Extensions.OpenTelemetry.csproj b/src/EventStore.Client.Extensions.OpenTelemetry/EventStore.Client.Extensions.OpenTelemetry.csproj new file mode 100644 index 000000000..e32bbacc7 --- /dev/null +++ b/src/EventStore.Client.Extensions.OpenTelemetry/EventStore.Client.Extensions.OpenTelemetry.csproj @@ -0,0 +1,16 @@ + + + + EventStore.Client.Extensions.OpenTelemetry + + + + EventStore.Client.Extensions.OpenTelemetry + Extensions used to facilitate instrumentation of the EventStore Client. + + + + + + + diff --git a/src/EventStore.Client.Extensions.OpenTelemetry/TracerProviderBuilderExtensions.cs b/src/EventStore.Client.Extensions.OpenTelemetry/TracerProviderBuilderExtensions.cs new file mode 100644 index 000000000..6a8b42b96 --- /dev/null +++ b/src/EventStore.Client.Extensions.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(EventStoreClientDiagnostics.InstrumentationName); +} \ No newline at end of file diff --git a/src/EventStore.Client.PersistentSubscriptions/EventStore.Client.PersistentSubscriptions.csproj b/src/EventStore.Client.PersistentSubscriptions/EventStore.Client.PersistentSubscriptions.csproj index cb7e38ce1..405a77405 100644 --- a/src/EventStore.Client.PersistentSubscriptions/EventStore.Client.PersistentSubscriptions.csproj +++ b/src/EventStore.Client.PersistentSubscriptions/EventStore.Client.PersistentSubscriptions.csproj @@ -3,7 +3,4 @@ The GRPC client API for Event Store Persistent Subscriptions. Get the open source or commercial versions of Event Store server from https://eventstore.com/ - - - diff --git a/src/EventStore.Client.PersistentSubscriptions/EventStorePersistentSubscriptionsClient.Read.cs b/src/EventStore.Client.PersistentSubscriptions/EventStorePersistentSubscriptionsClient.Read.cs index 148ebcf8d..d149973d5 100644 --- a/src/EventStore.Client.PersistentSubscriptions/EventStorePersistentSubscriptionsClient.Read.cs +++ b/src/EventStore.Client.PersistentSubscriptions/EventStorePersistentSubscriptionsClient.Read.cs @@ -1,6 +1,9 @@ using System.Threading.Channels; +using EventStore.Client.Diagnostics; using EventStore.Client.PersistentSubscriptions; using Grpc.Core; + +using static EventStore.Client.PersistentSubscriptions.PersistentSubscriptions; using static EventStore.Client.PersistentSubscriptions.ReadResp.ContentOneofCase; namespace EventStore.Client { @@ -12,18 +15,28 @@ partial class EventStorePersistentSubscriptionsClient { /// /// [Obsolete("SubscribeAsync is no longer supported. Use SubscribeToStream with manual acks instead.", false)] - public async Task SubscribeAsync(string streamName, string groupName, + 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) { + CancellationToken cancellationToken = default + ) { if (autoAck) { throw new InvalidOperationException( - $"AutoAck is no longer supported. Please use {nameof(SubscribeToStreamAsync)} with manual acks instead."); + $"AutoAck is no longer supported. Please use {nameof(SubscribeToStreamAsync)} with manual acks instead." + ); } - return await SubscribeToStreamAsync(streamName, groupName, eventAppeared, subscriptionDropped, - userCredentials, bufferSize, cancellationToken).ConfigureAwait(false); + return await SubscribeToStreamAsync( + streamName, + groupName, + eventAppeared, + subscriptionDropped, + userCredentials, + bufferSize, + cancellationToken + ).ConfigureAwait(false); } /// @@ -33,14 +46,22 @@ public async Task SubscribeAsync(string streamName, stri /// /// [Obsolete("SubscribeToStreamAsync is no longer supported. Use SubscribeToStream with manual acks instead.", false)] - public async Task SubscribeToStreamAsync(string streamName, string groupName, - Func eventAppeared, - Action? subscriptionDropped = null, - UserCredentials? userCredentials = null, int bufferSize = 10, - CancellationToken cancellationToken = default) { + 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) + .Confirm( + SubscribeToStream(streamName, groupName, bufferSize, userCredentials, cancellationToken), + eventAppeared, + subscriptionDropped ?? delegate { }, + _log, + userCredentials, + cancellationToken + ) .ConfigureAwait(false); } @@ -53,8 +74,10 @@ public async Task SubscribeToStreamAsync(string streamNa /// 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) { + public PersistentSubscriptionResult SubscribeToStream( + string streamName, string groupName, int bufferSize = 10, + UserCredentials? userCredentials = null, CancellationToken cancellationToken = default + ) { if (streamName == null) { throw new ArgumentNullException(nameof(streamName)); } @@ -77,7 +100,7 @@ public PersistentSubscriptionResult SubscribeToStream(string streamName, string var readOptions = new ReadReq.Types.Options { BufferSize = bufferSize, - GroupName = groupName, + GroupName = groupName, UuidOption = new ReadReq.Types.Options.Types.UUIDOption { Structured = new Empty() } }; @@ -87,29 +110,48 @@ public PersistentSubscriptionResult SubscribeToStream(string streamName, string 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 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.CallInvoker; - }, new() { Options = readOptions }, Settings, userCredentials, cancellationToken); + return channelInfo; + }, + new() { Options = readOptions }, + Settings, + userCredentials, + cancellationToken + ); } /// /// Subscribes to a persistent subscription to $all. Messages must be manually acknowledged /// [Obsolete("SubscribeToAllAsync is no longer supported. Use SubscribeToAll with manual acks instead.", false)] - 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) + 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); /// @@ -120,31 +162,34 @@ await SubscribeToStreamAsync(SystemStreams.AllStream, groupName, eventAppeared, /// The optional user credentials to perform operation with. /// The optional . /// - public PersistentSubscriptionResult SubscribeToAll(string groupName, int bufferSize = 10, - UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) => + 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 { - private const int MaxEventIdLength = 2000; - private readonly ReadReq _request; - private readonly Channel _channel; - private readonly CancellationTokenSource _cts; - private readonly CallOptions _callOptions; + const int MaxEventIdLength = 2000; + + readonly ReadReq _request; + readonly Channel _channel; + readonly CancellationTokenSource _cts; + readonly CallOptions _callOptions; - private AsyncDuplexStreamingCall? _call; - private int _messagesEnumerated; + 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. /// @@ -155,40 +200,43 @@ public class PersistentSubscriptionResult : IAsyncEnumerable, IAs /// public IAsyncEnumerable Messages { get { - if (Interlocked.Exchange(ref _messagesEnumerated, 1) == 1) { - throw new InvalidOperationException("Messages may only be enumerated once."); - } + 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(); - } + 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> selectCallInvoker, + internal PersistentSubscriptionResult( + string streamName, string groupName, + Func> selectChannelInfo, ReadReq request, EventStoreClientSettings settings, UserCredentials? userCredentials, - CancellationToken cancellationToken) { + CancellationToken cancellationToken + ) { StreamName = streamName; - GroupName = groupName; - + GroupName = groupName; + _request = request; - - _callOptions = EventStoreCallOptions.CreateStreaming(settings, userCredentials: userCredentials, - cancellationToken: cancellationToken); + + _callOptions = EventStoreCallOptions.CreateStreaming( + settings, + userCredentials: userCredentials, + cancellationToken: cancellationToken + ); _channel = Channel.CreateBounded(ReadBoundedChannelOptions); @@ -200,25 +248,38 @@ internal PersistentSubscriptionResult(string streamName, string groupName, async Task PumpMessages() { try { - var callInvoker = await selectCallInvoker(_cts.Token).ConfigureAwait(false); - var client = new PersistentSubscriptions.PersistentSubscriptions.PersistentSubscriptionsClient( - callInvoker); + 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)) { - await _channel.Writer.WriteAsync(response.ContentCase switch { + 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.SubscriptionConfirmation.SubscriptionId + ), + Event => new PersistentSubscriptionMessage.Event( + ConvertToResolvedEvent(response), response.Event.CountCase switch { ReadResp.Types.ReadEvent.CountOneofCase.RetryCount => response.Event.RetryCount, - _ => null - }), + _ => null + } + ), _ => PersistentSubscriptionMessage.Unknown.Instance - }, _cts.Token).ConfigureAwait(false); + }; + + if (subscriptionMessage is PersistentSubscriptionMessage.Event evnt) + EventStoreClientDiagnostics.ActivitySource.TraceSubscriptionEvent( + SubscriptionId, + evnt.ResolvedEvent, + channelInfo, + settings, + userCredentials + ); + + await _channel.Writer.WriteAsync(subscriptionMessage, _cts.Token).ConfigureAwait(false); } _channel.Writer.TryComplete(); @@ -240,11 +301,14 @@ when rex2.Status.Detail.Contains("No grpc-status found on response"): } #endif if (ex is PersistentSubscriptionNotFoundException) { - await _channel.Writer.WriteAsync(PersistentSubscriptionMessage.NotFound.Instance, - cancellationToken).ConfigureAwait(false); + await _channel.Writer + .WriteAsync(PersistentSubscriptionMessage.NotFound.Instance, cancellationToken) + .ConfigureAwait(false); + _channel.Writer.TryComplete(); return; } + _channel.Writer.TryComplete(ex); } } @@ -280,7 +344,6 @@ public Task Ack(params ResolvedEvent[] resolvedEvents) => 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). /// @@ -298,61 +361,69 @@ public Task Nack(PersistentSubscriptionNakEventAction action, string reason, par /// 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 Task Nack(PersistentSubscriptionNakEventAction action, string reason, params ResolvedEvent[] resolvedEvents) => + Nack(action, reason, Array.ConvertAll(resolvedEvents, re => re.OriginalEvent.EventId)); - private static ResolvedEvent ConvertToResolvedEvent(ReadResp response) => new( + 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 - }); + _ => null + } + ); - private Task AckInternal(params Uuid[] eventIds) { + Task AckInternal(params Uuid[] eventIds) { if (eventIds.Length > MaxEventIdLength) { throw new ArgumentException( - $"The number of eventIds exceeds the maximum length of {MaxEventIdLength}.", nameof(eventIds)); + $"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()) + : _call.RequestStream.WriteAsync( + new ReadReq { + Ack = new ReadReq.Types.Ack { + Ids = { + Array.ConvertAll(eventIds, id => id.ToDto()) + } } } - }); + ); } - private Task NackInternal(Uuid[] eventIds, PersistentSubscriptionNakEventAction action, string reason) { + 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)); + $"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 + : _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 + } } - }); + ); } - private static EventRecord? ConvertToEventRecord(ReadResp.Types.ReadEvent.Types.RecordedEvent? e) => + static EventRecord? ConvertToEventRecord(ReadResp.Types.ReadEvent.Types.RecordedEvent? e) => e is null ? null : new EventRecord( @@ -362,7 +433,8 @@ e is null new Position(e.CommitPosition, e.PreparePosition), e.Metadata, e.Data.ToByteArray(), - e.CustomMetadata.ToByteArray()); + e.CustomMetadata.ToByteArray() + ); /// public async ValueTask DisposeAsync() { @@ -375,9 +447,11 @@ static async Task CastAndDispose(IDisposable? resource) { switch (resource) { case null: return; + case IAsyncDisposable resourceAsyncDisposable: await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false); break; + default: resource.Dispose(); break; @@ -385,7 +459,6 @@ static async Task CastAndDispose(IDisposable? resource) { } } - /// public void Dispose() { _cts.Dispose(); @@ -393,12 +466,10 @@ public void Dispose() { } /// - public async IAsyncEnumerator GetAsyncEnumerator( - CancellationToken cancellationToken = default) { + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { await foreach (var message in Messages.WithCancellation(cancellationToken)) { - if (message is not PersistentSubscriptionMessage.Event(var resolvedEvent, _)) { - continue; - } + if (message is not PersistentSubscriptionMessage.Event(var resolvedEvent, _)) + continue; yield return resolvedEvent; } diff --git a/src/EventStore.Client.Streams/EventStore.Client.Streams.csproj b/src/EventStore.Client.Streams/EventStore.Client.Streams.csproj index 4aa697465..c878127a5 100644 --- a/src/EventStore.Client.Streams/EventStore.Client.Streams.csproj +++ b/src/EventStore.Client.Streams/EventStore.Client.Streams.csproj @@ -3,7 +3,4 @@ The GRPC client API for Event Store Streams. Get the open source or commercial versions of Event Store server from https://eventstore.com/ - - - diff --git a/src/EventStore.Client.Streams/EventStoreClient.Append.cs b/src/EventStore.Client.Streams/EventStoreClient.Append.cs index 9d0842855..d85126c3f 100644 --- a/src/EventStore.Client.Streams/EventStoreClient.Append.cs +++ b/src/EventStore.Client.Streams/EventStoreClient.Append.cs @@ -1,14 +1,16 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics; using System.Threading.Channels; using Google.Protobuf; using EventStore.Client.Streams; using Grpc.Core; using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; +using EventStore.Client.Diagnostics; +using EventStore.Diagnostics; +using EventStore.Diagnostics.Telemetry; +using EventStore.Diagnostics.Tracing; +using static EventStore.Client.Streams.AppendResp.Types.WrongExpectedVersion; +using static EventStore.Client.Streams.Streams; namespace EventStore.Client { public partial class EventStoreClient { @@ -30,24 +32,29 @@ public async Task AppendToStreamAsync( Action? configureOperationOptions = null, TimeSpan? deadline = null, UserCredentials? userCredentials = null, - CancellationToken cancellationToken = default) { + CancellationToken cancellationToken = default + ) { var options = Settings.OperationOptions.Clone(); configureOperationOptions?.Invoke(options); _log.LogDebug("Append to stream - {streamName}@{expectedRevision}.", streamName, expectedRevision); - var batchAppender = _streamAppender; - var task = - userCredentials == null && await batchAppender.IsUsable().ConfigureAwait(false) - ? batchAppender.Append(streamName, expectedRevision, eventData, deadline, cancellationToken) - : AppendToStreamInternal( - (await GetChannelInfo(cancellationToken).ConfigureAwait(false)).CallInvoker, - new AppendReq { - Options = new AppendReq.Types.Options { - StreamIdentifier = streamName, - Revision = expectedRevision - } - }, eventData, options, deadline, userCredentials, cancellationToken); + 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); } @@ -70,29 +77,35 @@ public async Task AppendToStreamAsync( Action? configureOperationOptions = null, TimeSpan? deadline = null, UserCredentials? userCredentials = null, - CancellationToken cancellationToken = default) { + CancellationToken cancellationToken = default + ) { var operationOptions = Settings.OperationOptions.Clone(); configureOperationOptions?.Invoke(operationOptions); - _log.LogDebug("Append to stream - {streamName}@{expectedRevision}.", streamName, expectedState); + _log.LogDebug("Append to stream - {streamName}@{expectedState}.", streamName, expectedState); - var batchAppender = _streamAppender; var task = - userCredentials == null && await batchAppender.IsUsable().ConfigureAwait(false) - ? batchAppender.Append(streamName, expectedState, eventData, deadline, cancellationToken) + userCredentials == null && await _batchAppender.IsUsable().ConfigureAwait(false) + ? _batchAppender.Append(streamName, expectedState, eventData, deadline, cancellationToken) : AppendToStreamInternal( - (await GetChannelInfo(cancellationToken).ConfigureAwait(false)).CallInvoker, + await GetChannelInfo(cancellationToken).ConfigureAwait(false), new AppendReq { - Options = new AppendReq.Types.Options { + Options = new() { StreamIdentifier = streamName } - }.WithAnyStreamRevision(expectedState), eventData, operationOptions, deadline, userCredentials, - cancellationToken); + }.WithAnyStreamRevision(expectedState), + eventData, + operationOptions, + deadline, + userCredentials, + cancellationToken + ); + return (await task.ConfigureAwait(false)).OptionallyThrowWrongExpectedVersionException(operationOptions); } - private async ValueTask AppendToStreamInternal( - CallInvoker callInvoker, + ValueTask AppendToStreamInternal( + ChannelInfo channelInfo, AppendReq header, IEnumerable eventData, EventStoreClientOperationOptions operationOptions, @@ -100,49 +113,58 @@ private async ValueTask AppendToStreamInternal( UserCredentials? userCredentials, CancellationToken cancellationToken ) { - using var call = new Streams.Streams.StreamsClient(callInvoker).Append( - EventStoreCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken) - ); + var tags = new ActivityTagsCollection() + .WithRequiredTag(TelemetryTags.EventStore.Stream, header.Options.StreamIdentifier.StreamName.ToStringUtf8()) + .WithGrpcChannelServerTags(channelInfo) + .WithClientSettingsServerTags(Settings) + .WithOptionalTag(TelemetryTags.Database.User, userCredentials?.Username ?? Settings.DefaultCredentials?.Username); + + return EventStoreClientDiagnostics.ActivitySource.TraceClientOperation(Operation, TracingConstants.Operations.Append, tags); - await call.RequestStream.WriteAsync(header).ConfigureAwait(false); + async ValueTask Operation() { + using var call = new StreamsClient(channelInfo.CallInvoker) + .Append(EventStoreCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); - foreach (var e in eventData) { - await call.RequestStream.WriteAsync( - new AppendReq { - ProposedMessage = new AppendReq.Types.ProposedMessage { + 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.Span), + CustomMetadata = ByteString.CopyFrom(e.Metadata.InjectTracingContext(Activity.Current)), Metadata = { { Constants.Metadata.Type, e.Type }, { Constants.Metadata.ContentType, e.ContentType } } - }, - } - ).ConfigureAwait(false); - } + } + }; + + await call.RequestStream.WriteAsync(appendReq).ConfigureAwait(false); + } - await call.RequestStream.CompleteAsync().ConfigureAwait(false); + await call.RequestStream.CompleteAsync().ConfigureAwait(false); - var response = await call.ResponseAsync.ConfigureAwait(false); + var response = await call.ResponseAsync.ConfigureAwait(false); - if (response.Success != null) - return HandleSuccessAppend(response, header); + if (response.Success is not null) + return HandleSuccessAppend(response, header); - if (response.WrongExpectedVersion == null) - throw new InvalidOperationException("The operation completed with an unexpected result."); + if (response.WrongExpectedVersion is null) + throw new InvalidOperationException("The operation completed with an unexpected result."); - return HandleWrongExpectedRevision(response, header, operationOptions); + return HandleWrongExpectedRevision(response, header, operationOptions); + } } - private IWriteResult HandleSuccessAppend(AppendResp response, AppendReq header) { - var currentRevision = response.Success.CurrentRevisionOptionCase == - AppendResp.Types.Success.CurrentRevisionOptionOneofCase.NoStream + 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 + var position = response.Success.PositionOptionCase == AppendResp.Types.Success.PositionOptionOneofCase.Position ? new Position(response.Success.Position.CommitPosition, response.Success.Position.PreparePosition) : default; @@ -150,22 +172,19 @@ private IWriteResult HandleSuccessAppend(AppendResp response, AppendReq header) "Append to stream succeeded - {streamName}@{logPosition}/{nextExpectedVersion}.", header.Options.StreamIdentifier, position, - currentRevision); + currentRevision + ); return new SuccessResult(currentRevision, position); } - private IWriteResult HandleWrongExpectedRevision( + IWriteResult HandleWrongExpectedRevision( AppendResp response, AppendReq header, EventStoreClientOperationOptions operationOptions ) { - var actualStreamRevision = - response.WrongExpectedVersion.CurrentRevisionOptionCase switch { - AppendResp.Types.WrongExpectedVersion.CurrentRevisionOptionOneofCase - .CurrentNoStream => - StreamRevision.None, - _ => new StreamRevision(response.WrongExpectedVersion.CurrentRevision) - }; - + 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, @@ -174,9 +193,7 @@ private IWriteResult HandleWrongExpectedRevision( ); if (operationOptions.ThrowOnAppendFailure) { - if (response.WrongExpectedVersion.ExpectedRevisionOptionCase == AppendResp.Types - .WrongExpectedVersion.ExpectedRevisionOptionOneofCase - .ExpectedRevision) { + if (response.WrongExpectedVersion.ExpectedRevisionOptionCase == ExpectedRevisionOptionOneofCase.ExpectedRevision) { throw new WrongExpectedVersionException( header.Options.StreamIdentifier!, new StreamRevision(response.WrongExpectedVersion.ExpectedRevision), @@ -184,19 +201,12 @@ private IWriteResult HandleWrongExpectedRevision( ); } - var expectedStreamState = - response.WrongExpectedVersion.ExpectedRevisionOptionCase switch { - AppendResp.Types.WrongExpectedVersion.ExpectedRevisionOptionOneofCase - .ExpectedAny => - StreamState.Any, - AppendResp.Types.WrongExpectedVersion.ExpectedRevisionOptionOneofCase - .ExpectedNoStream => - StreamState.NoStream, - AppendResp.Types.WrongExpectedVersion.ExpectedRevisionOptionOneofCase - .ExpectedStreamExists => - StreamState.StreamExists, - _ => StreamState.Any - }; + 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!, @@ -205,9 +215,7 @@ private IWriteResult HandleWrongExpectedRevision( ); } - var expectedRevision = response.WrongExpectedVersion.ExpectedRevisionOptionCase - == AppendResp.Types.WrongExpectedVersion.ExpectedRevisionOptionOneofCase - .ExpectedRevision + var expectedRevision = response.WrongExpectedVersion.ExpectedRevisionOptionCase == ExpectedRevisionOptionOneofCase.ExpectedRevision ? new StreamRevision(response.WrongExpectedVersion.ExpectedRevision) : StreamRevision.None; @@ -218,150 +226,200 @@ private IWriteResult HandleWrongExpectedRevision( ); } - private class StreamAppender : IDisposable { - private readonly EventStoreClientSettings _settings; - private readonly CancellationToken _cancellationToken; - private readonly Action _onException; - private readonly Channel _channel; - private readonly ConcurrentDictionary> _pendingRequests; - - private readonly Task?> _callTask; - - public StreamAppender(EventStoreClientSettings settings, - Task?> callTask, CancellationToken cancellationToken, - Action onException) { + class StreamAppender : IDisposable { + readonly EventStoreClientSettings _settings; + readonly CancellationToken _cancellationToken; + readonly Action _onException; + readonly Channel _channel; + readonly ConcurrentDictionary> _pendingRequests; + readonly TaskCompletionSource _isUsable; + + ChannelInfo? _channelInfo; + AsyncDuplexStreamingCall? _call; + + public StreamAppender( + EventStoreClientSettings settings, + ValueTask channelInfoTask, + CancellationToken cancellationToken, + Action onException + ) { _settings = settings; - _callTask = callTask; _cancellationToken = cancellationToken; _onException = onException; - _channel = System.Threading.Channels.Channel.CreateBounded(10000); + _channel = Channel.CreateBounded(10000); _pendingRequests = new ConcurrentDictionary>(); - _ = Task.Factory.StartNew(Send); - _ = Task.Factory.StartNew(Receive); + _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, 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 ValueTask Append( + string streamName, StreamState expectedStreamState, + IEnumerable events, TimeSpan? timeoutAfter, + CancellationToken cancellationToken = default + ) => + AppendInternal( + BatchAppendReq.Types.Options.Create(streamName, expectedStreamState, timeoutAfter), + events, + cancellationToken + ); - public async ValueTask IsUsable() { - var call = await _callTask.ConfigureAwait(false); - return call != null; - } + public Task IsUsable() => _isUsable.Task; + + ValueTask AppendInternal( + BatchAppendReq.Types.Options options, + IEnumerable events, + CancellationToken cancellationToken + ) { + var tags = new ActivityTagsCollection() + .WithRequiredTag(TelemetryTags.EventStore.Stream, options.StreamIdentifier.StreamName.ToStringUtf8()) + .WithGrpcChannelServerTags(_channelInfo) + .WithClientSettingsServerTags(_settings) + .WithOptionalTag(TelemetryTags.Database.User, _settings.DefaultCredentials?.Username); + + return EventStoreClientDiagnostics.ActivitySource.TraceClientOperation( + Operation, + TracingConstants.Operations.Append, + tags + ); - private async Task Receive() { - try { - var call = await _callTask.ConfigureAwait(false); - if (call is null) { - _channel.Writer.TryComplete( - new NotSupportedException("Server does not support batch append")); - return; - } + async ValueTask Operation() { + var correlationId = Uuid.NewUuid(); - await foreach (var response in call.ResponseStream.ReadAllAsync(_cancellationToken) - .ConfigureAwait(false)) { - if (!_pendingRequests.TryRemove(Uuid.FromDto(response.CorrelationId), out var writeResult)) { - continue; // TODO: Log? - } + var complete = _pendingRequests.GetOrAdd(correlationId, new TaskCompletionSource()); - try { - writeResult.TrySetResult(response.ToWriteResult()); - } catch (Exception ex) { - writeResult.TrySetException(ex); - } + 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; } - } 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 - _onException(ex); - foreach (var request in _pendingRequests) { - request.Value.TrySetException(ex); - } + return await complete.Task.ConfigureAwait(false); } } - private async Task Send() { - var call = await _callTask.ConfigureAwait(false); - if (call is null) - throw new NotSupportedException("Server does not support batch append"); + 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( + EventStoreCallOptions.CreateStreaming( + _settings, + userCredentials: _settings.DefaultCredentials, + cancellationToken: _cancellationToken + ) + ); + + _ = Task.Run(Send, _cancellationToken); + _ = Task.Run(Receive, _cancellationToken); - await foreach (var appendRequest in _channel.Reader.ReadAllAsync(_cancellationToken) - .ConfigureAwait(false)) { - await call.RequestStream.WriteAsync(appendRequest).ConfigureAwait(false); + _isUsable.TrySetResult(true); + } catch (Exception ex) { + _isUsable.TrySetException(ex); + _onException(ex); } - await call.RequestStream.CompleteAsync().ConfigureAwait(false); - } + return; - private async ValueTask AppendInternal(BatchAppendReq.Types.Options options, - IEnumerable events, CancellationToken cancellationToken) { - var batchSize = 0; - var correlationId = Uuid.NewUuid(); - var correlationIdDto = correlationId.ToDto(); + async Task Send() { + if (_call is null) return; - var complete = _pendingRequests.GetOrAdd(correlationId, new TaskCompletionSource()); + await foreach (var appendRequest in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) + await _call.RequestStream.WriteAsync(appendRequest).ConfigureAwait(false); - try { - foreach (var appendRequest in GetRequests()) { - 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; + await _call.RequestStream.CompleteAsync().ConfigureAwait(false); } - return await complete.Task.ConfigureAwait(false); + async Task Receive() { + if (_call is null) return; - IEnumerable GetRequests() { - bool first = true; - var proposedMessages = new List(); - foreach (var @event in events) { - var proposedMessage = new BatchAppendReq.Types.ProposedMessage { - Data = ByteString.CopyFrom(@event.Data.Span), - CustomMetadata = ByteString.CopyFrom(@event.Metadata.Span), - Id = @event.EventId.ToDto(), - Metadata = { - {Constants.Metadata.Type, @event.Type}, - {Constants.Metadata.ContentType, @event.ContentType} + 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? } - }; - - proposedMessages.Add(proposedMessage); - if ((batchSize += proposedMessage.CalculateSize()) < - _settings.OperationOptions.BatchAppendSize) { - continue; + 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); - yield return new BatchAppendReq { - ProposedMessages = {proposedMessages}, - CorrelationId = correlationIdDto, - Options = first ? options : null - }; - first = false; - proposedMessages.Clear(); - batchSize = 0; + _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}, - IsFinal = true, + 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/EventStore.Client.Streams/EventStoreClient.Metadata.cs b/src/EventStore.Client.Streams/EventStoreClient.Metadata.cs index 6581bd94b..19de629e7 100644 --- a/src/EventStore.Client.Streams/EventStoreClient.Metadata.cs +++ b/src/EventStore.Client.Streams/EventStoreClient.Metadata.cs @@ -97,7 +97,7 @@ private async Task SetStreamMetadataInternal(StreamMetadata metada CancellationToken cancellationToken) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - return await AppendToStreamInternal(channelInfo.CallInvoker, appendReq, new[] { + 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/EventStore.Client.Streams/EventStoreClient.Subscriptions.cs b/src/EventStore.Client.Streams/EventStoreClient.Subscriptions.cs index f82bd5d07..87497e7ce 100644 --- a/src/EventStore.Client.Streams/EventStoreClient.Subscriptions.cs +++ b/src/EventStore.Client.Streams/EventStoreClient.Subscriptions.cs @@ -1,6 +1,8 @@ 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 { @@ -24,10 +26,15 @@ public Task SubscribeToAllAsync( Action? subscriptionDropped = default, SubscriptionFilterOptions? filterOptions = null, UserCredentials? userCredentials = null, - CancellationToken cancellationToken = default) => StreamSubscription.Confirm( + CancellationToken cancellationToken = default + ) => StreamSubscription.Confirm( SubscribeToAll(start, resolveLinkTos, filterOptions, userCredentials, cancellationToken), - eventAppeared, subscriptionDropped, _log, filterOptions?.CheckpointReached, - cancellationToken: cancellationToken); + eventAppeared, + subscriptionDropped, + _log, + filterOptions?.CheckpointReached, + cancellationToken: cancellationToken + ); /// /// Subscribes to all events. @@ -43,19 +50,23 @@ public StreamSubscriptionResult SubscribeToAll( bool resolveLinkTos = false, SubscriptionFilterOptions? filterOptions = null, UserCredentials? userCredentials = null, - CancellationToken cancellationToken = default) => new(async _ => { - var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - return channelInfo.CallInvoker; - }, 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); + 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. @@ -69,15 +80,21 @@ public StreamSubscriptionResult SubscribeToAll( /// The optional . /// [Obsolete("SubscribeToStreamAsync is no longer supported. Use SubscribeToStream instead.", false)] - public Task SubscribeToStreamAsync(string streamName, - FromStream start, - Func eventAppeared, - bool resolveLinkTos = false, - Action? subscriptionDropped = default, - UserCredentials? userCredentials = null, - CancellationToken cancellationToken = default) => StreamSubscription.Confirm( + 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); + eventAppeared, + subscriptionDropped, + _log, + cancellationToken: cancellationToken + ); /// /// Subscribes to a stream from a checkpoint. @@ -93,28 +110,33 @@ public StreamSubscriptionResult SubscribeToStream( FromStream start, bool resolveLinkTos = false, UserCredentials? userCredentials = null, - CancellationToken cancellationToken = default) => new(async _ => { - var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - return channelInfo.CallInvoker; - }, 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); + 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 AsyncServerStreamingCall? _call; + private readonly ReadReq _request; + private readonly Channel _channel; + private readonly CancellationTokenSource _cts; + private readonly CallOptions _callOptions; + private readonly EventStoreClientSettings _settings; + private AsyncServerStreamingCall? _call; private int _messagesEnumerated; @@ -128,34 +150,44 @@ public class StreamSubscriptionResult : IAsyncEnumerable, IAsyncD /// public IAsyncEnumerable Messages { get { - if (Interlocked.Exchange(ref _messagesEnumerated, 1) == 1) { - throw new InvalidOperationException("Messages may only be enumerated once."); - } + 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; - } + if (message is StreamMessage.SubscriptionConfirmation(var subscriptionId)) + SubscriptionId = subscriptionId; yield return message; } - } finally { - _cts.Cancel(); - } - } + } + finally { +#if NET8_0_OR_GREATER + await _cts.CancelAsync().ConfigureAwait(false); +#else + _cts.Cancel(); +#endif + } + } } } - internal StreamSubscriptionResult(Func> selectCallInvoker, + internal StreamSubscriptionResult( + Func> selectChannelInfo, ReadReq request, EventStoreClientSettings settings, UserCredentials? userCredentials, - CancellationToken cancellationToken) { - _request = request; - _callOptions = EventStoreCallOptions.CreateStreaming(settings, userCredentials: userCredentials, - cancellationToken: cancellationToken); + CancellationToken cancellationToken + ) { + _request = request; + _settings = settings; + + _callOptions = EventStoreCallOptions.CreateStreaming( + settings, + userCredentials: userCredentials, + cancellationToken: cancellationToken + ); _channel = Channel.CreateBounded(ReadBoundedChannelOptions); @@ -171,30 +203,46 @@ internal StreamSubscriptionResult(Func> sel async Task PumpMessages() { try { - var callInvoker = await selectCallInvoker(_cts.Token).ConfigureAwait(false); - var client = new Streams.Streams.StreamsClient(callInvoker); + 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)) { - await _channel.Writer.WriteAsync(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 - }, _cts.Token).ConfigureAwait(false); - } + 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) + EventStoreClientDiagnostics.ActivitySource.TraceSubscriptionEvent( + SubscriptionId, + evt.ResolvedEvent, + channelInfo, + _settings, + userCredentials + ); + + await _channel.Writer + .WriteAsync(subscriptionMessage, _cts.Token) + .ConfigureAwait(false); + } _channel.Writer.Complete(); } catch (Exception ex) { @@ -205,6 +253,7 @@ async Task PumpMessages() { /// public async ValueTask DisposeAsync() { + //TODO SS: Check if `CastAndDispose` is still relevant await CastAndDispose(_cts).ConfigureAwait(false); await CastAndDispose(_call).ConfigureAwait(false); @@ -214,9 +263,11 @@ static async ValueTask CastAndDispose(IDisposable? resource) { switch (resource) { case null: return; - case IAsyncDisposable resourceAsyncDisposable: - await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false); + + case IAsyncDisposable disposable: + await disposable.DisposeAsync().ConfigureAwait(false); break; + default: resource.Dispose(); break; @@ -231,19 +282,21 @@ public void Dispose() { } /// - public async IAsyncEnumerator GetAsyncEnumerator( - CancellationToken cancellationToken = default) { + 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; - } + 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(); + } + finally { +#if NET8_0_OR_GREATER + await _cts.CancelAsync().ConfigureAwait(false); +#else + _cts.Cancel(); +#endif } } } diff --git a/src/EventStore.Client.Streams/EventStoreClient.cs b/src/EventStore.Client.Streams/EventStoreClient.cs index 361e6e2d4..700df3c3f 100644 --- a/src/EventStore.Client.Streams/EventStoreClient.cs +++ b/src/EventStore.Client.Streams/EventStoreClient.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Threading.Channels; -using EventStore.Client.Streams; using Grpc.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -12,32 +11,48 @@ namespace EventStore.Client { /// The client used for operations on streams. /// public sealed partial class EventStoreClient : EventStoreClientBase { - private static readonly JsonSerializerOptions StreamMetadataJsonSerializerOptions = new() { Converters = { StreamMetadataJsonConverter.Instance }, }; - private static BoundedChannelOptions ReadBoundedChannelOptions = new (1) { - SingleReader = true, - SingleWriter = true, + private static BoundedChannelOptions ReadBoundedChannelOptions = new(1) { + SingleReader = true, + SingleWriter = true, AllowSynchronousContinuations = true }; - - private readonly ILogger _log; - private Lazy _streamAppenderLazy; - private StreamAppender _streamAppender => _streamAppenderLazy.Value; - private readonly CancellationTokenSource _disposedTokenSource; + private readonly ILogger _log; + private Lazy _batchAppenderLazy; + private StreamAppender _batchAppender => _batchAppenderLazy.Value; + private readonly CancellationTokenSource _disposedTokenSource; private 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), + [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 + ), }; /// @@ -53,29 +68,24 @@ public EventStoreClient(IOptions options) : this(optio public EventStoreClient(EventStoreClientSettings? settings = null) : base(settings, ExceptionMap) { _log = Settings.LoggerFactory?.CreateLogger() ?? new NullLogger(); _disposedTokenSource = new CancellationTokenSource(); - _streamAppenderLazy = new Lazy(CreateStreamAppender); + _batchAppenderLazy = new Lazy(CreateStreamAppender); } private void SwapStreamAppender(Exception ex) => - Interlocked.Exchange(ref _streamAppenderLazy, new Lazy(CreateStreamAppender)).Value.Dispose(); + 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. - private StreamAppender CreateStreamAppender() { - return new StreamAppender(Settings, GetCall(), _disposedTokenSource.Token, SwapStreamAppender); - - async Task?> GetCall() { - var channelInfo = await GetChannelInfo(_disposedTokenSource.Token).ConfigureAwait(false); - if (!channelInfo.ServerCapabilities.SupportsBatchAppend) - return null; - - var client = new Streams.Streams.StreamsClient(channelInfo.CallInvoker); - - return client.BatchAppend(EventStoreCallOptions.CreateStreaming(Settings, - userCredentials: Settings.DefaultCredentials, cancellationToken: _disposedTokenSource.Token)); - } - } - - private static ReadReq.Types.Options.Types.FilterOptions? GetFilterOptions(IEventFilter? filter, uint checkpointInterval = 0) { + private StreamAppender CreateStreamAppender() => new StreamAppender( + Settings, + GetChannelInfo(_disposedTokenSource.Token), + _disposedTokenSource.Token, + SwapStreamAppender + ); + + private static ReadReq.Types.Options.Types.FilterOptions? GetFilterOptions( + IEventFilter? filter, uint checkpointInterval = 0 + ) { if (filter == null) { return null; } @@ -131,21 +141,25 @@ private StreamAppender CreateStreamAppender() { return options; } - private static ReadReq.Types.Options.Types.FilterOptions? GetFilterOptions(SubscriptionFilterOptions? filterOptions) + private static ReadReq.Types.Options.Types.FilterOptions? GetFilterOptions( + SubscriptionFilterOptions? filterOptions + ) => filterOptions == null ? null : GetFilterOptions(filterOptions.Filter, filterOptions.CheckpointInterval); /// public override void Dispose() { - if (_streamAppenderLazy.IsValueCreated) - _streamAppenderLazy.Value.Dispose(); + if (_batchAppenderLazy.IsValueCreated) + _batchAppenderLazy.Value.Dispose(); + _disposedTokenSource.Dispose(); base.Dispose(); } /// public override async ValueTask DisposeAsync() { - if (_streamAppenderLazy.IsValueCreated) - _streamAppenderLazy.Value.Dispose(); + if (_batchAppenderLazy.IsValueCreated) + _batchAppenderLazy.Value.Dispose(); + _disposedTokenSource.Dispose(); await base.DisposeAsync().ConfigureAwait(false); } diff --git a/src/EventStore.Client.Streams/StreamSubscription.cs b/src/EventStore.Client.Streams/StreamSubscription.cs index 9019eda64..c6b9e8ae6 100644 --- a/src/EventStore.Client.Streams/StreamSubscription.cs +++ b/src/EventStore.Client.Streams/StreamSubscription.cs @@ -7,26 +7,28 @@ namespace EventStore.Client { /// [Obsolete] public class StreamSubscription : IDisposable { - private readonly EventStoreClient.StreamSubscriptionResult _subscription; - private readonly IAsyncEnumerator _messages; - private readonly Func _eventAppeared; - private readonly Func _checkpointReached; + private readonly EventStoreClient.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; + 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(EventStoreClient.StreamSubscriptionResult subscription, + internal static async Task Confirm( + EventStoreClient.StreamSubscriptionResult subscription, Func eventAppeared, Action? subscriptionDropped, ILogger log, Func? checkpointReached = null, - CancellationToken cancellationToken = default) { + CancellationToken cancellationToken = default + ) { var messages = subscription.Messages; var enumerator = messages.GetAsyncEnumerator(cancellationToken); @@ -35,26 +37,36 @@ enumerator.Current is not StreamMessage.SubscriptionConfirmation(var subscriptio throw new InvalidOperationException($"Subscription to {enumerator} could not be confirmed."); } - return new StreamSubscription(subscription, enumerator, subscriptionId, eventAppeared, subscriptionDropped, - log, checkpointReached, cancellationToken); + return new StreamSubscription( + subscription, + enumerator, + subscriptionId, + eventAppeared, + subscriptionDropped, + log, + checkpointReached, + cancellationToken + ); } - private StreamSubscription(EventStoreClient.StreamSubscriptionResult subscription, + private StreamSubscription( + EventStoreClient.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; + 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; + SubscriptionId = subscriptionId; _log.LogDebug("Subscription {subscriptionId} confirmed.", SubscriptionId); @@ -77,11 +89,14 @@ private async Task Subscribe() { 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 @@ -105,6 +120,7 @@ await _checkpointReached(this, position, _cts.Token) "Subscription {subscriptionId} was dropped because the subscriber made an error.", SubscriptionId ); + SubscriptionDropped(SubscriptionDroppedReason.SubscriberError, ex); return; @@ -114,13 +130,18 @@ await _checkpointReached(this, position, _cts.Token) ex.Status.Detail.Contains("Call canceled by the client.")) { _log.LogInformation( "Subscription {subscriptionId} was dropped because cancellation was requested by the client.", - SubscriptionId); + SubscriptionId + ); + SubscriptionDropped(SubscriptionDroppedReason.Disposed, ex); } catch (Exception ex) { if (_subscriptionDroppedInvoked == 0) { - _log.LogError(ex, + _log.LogError( + ex, "Subscription {subscriptionId} was dropped because an error occurred on the server.", - SubscriptionId); + SubscriptionId + ); + SubscriptionDropped(SubscriptionDroppedReason.ServerError, ex); } } diff --git a/src/EventStore.Client/ArrayExtensions.cs b/src/EventStore.Client/ArrayExtensions.cs deleted file mode 100644 index e5f6a9bda..000000000 --- a/src/EventStore.Client/ArrayExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace EventStore.Client { - internal static class ArrayExtensions { - public static void RandomShuffle(this T[] arr, int i, int j) { - if (i >= j) - return; - var rnd = new Random(Guid.NewGuid().GetHashCode()); - for (int k = i; k < j; ++k) { - var index = rnd.Next(k, j + 1); - var tmp = arr[index]; - arr[index] = arr[k]; - arr[k] = tmp; - } - } - } -} diff --git a/src/EventStore.Client.Common/AsyncStreamReaderExtensions.cs b/src/EventStore.Client/Common/AsyncStreamReaderExtensions.cs similarity index 100% rename from src/EventStore.Client.Common/AsyncStreamReaderExtensions.cs rename to src/EventStore.Client/Common/AsyncStreamReaderExtensions.cs diff --git a/src/EventStore.Client.Common/Constants.cs b/src/EventStore.Client/Common/Constants.cs similarity index 90% rename from src/EventStore.Client.Common/Constants.cs rename to src/EventStore.Client/Common/Constants.cs index 3e0279e6b..2ed9d7c82 100644 --- a/src/EventStore.Client.Common/Constants.cs +++ b/src/EventStore.Client/Common/Constants.cs @@ -39,10 +39,11 @@ public static class Exceptions { } 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 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"; @@ -58,4 +59,4 @@ public static class Headers { public const string ConnectionName = "connection-name"; public const string RequiresLeader = "requires-leader"; } -} \ No newline at end of file +} diff --git a/src/EventStore.Client/Common/Diagnostics/ActivitySourceExtensions.cs b/src/EventStore.Client/Common/Diagnostics/ActivitySourceExtensions.cs new file mode 100644 index 000000000..985d6133d --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/ActivitySourceExtensions.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using EventStore.Diagnostics; +using EventStore.Diagnostics.Telemetry; +using EventStore.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, + EventStoreClientSettings settings, + UserCredentials? userCredentials + ) { + var parentContext = resolvedEvent.OriginalEvent.Metadata.ExtractPropagationContext(); + + if (parentContext is null) return; + + var tags = new ActivityTagsCollection() + .WithRequiredTag(TelemetryTags.EventStore.Stream, resolvedEvent.OriginalEvent.EventStreamId) + .WithOptionalTag(TelemetryTags.EventStore.SubscriptionId, subscriptionId) + .WithRequiredTag(TelemetryTags.EventStore.EventId, resolvedEvent.OriginalEvent.EventId.ToString()) + .WithRequiredTag(TelemetryTags.EventStore.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 + ) { + (tags ??= new ActivityTagsCollection()) + .WithRequiredTag(TelemetryTags.Database.System, "eventstoredb") + .WithRequiredTag(TelemetryTags.Database.Operation, operationName); + + return source + .CreateActivity( + operationName, + activityKind, + parentContext ?? default, + tags, + idFormat: ActivityIdFormat.W3C + ) + ?.Start(); + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/ActivityTagsCollectionExtensions.cs b/src/EventStore.Client/Common/Diagnostics/ActivityTagsCollectionExtensions.cs new file mode 100644 index 000000000..865959eb9 --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/ActivityTagsCollectionExtensions.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using EventStore.Diagnostics; +using EventStore.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, EventStoreClientSettings 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/EventStore.Client/Common/Diagnostics/Core/ActivityExtensions.cs b/src/EventStore.Client/Common/Diagnostics/Core/ActivityExtensions.cs new file mode 100644 index 000000000..4594d1000 --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/ActivityExtensions.cs @@ -0,0 +1,52 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using EventStore.Diagnostics.Telemetry; +using EventStore.Diagnostics.Tracing; + +namespace EventStore.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; + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Core/ActivityStatus.cs b/src/EventStore.Client/Common/Diagnostics/Core/ActivityStatus.cs new file mode 100644 index 000000000..a25b7326f --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/ActivityStatus.cs @@ -0,0 +1,13 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; + +namespace EventStore.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/EventStore.Client/Common/Diagnostics/Core/ActivityStatusCodeHelper.cs b/src/EventStore.Client/Common/Diagnostics/Core/ActivityStatusCodeHelper.cs new file mode 100644 index 000000000..77810aa6c --- /dev/null +++ b/src/EventStore.Client/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 EventStore.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 + }; +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs b/src/EventStore.Client/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs new file mode 100644 index 000000000..15aa0662e --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/ActivityTagsCollectionExtensions.cs @@ -0,0 +1,25 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace EventStore.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/EventStore.Client/Common/Diagnostics/Core/ExceptionExtensions.cs b/src/EventStore.Client/Common/Diagnostics/Core/ExceptionExtensions.cs new file mode 100644 index 000000000..8403c3c01 --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/ExceptionExtensions.cs @@ -0,0 +1,25 @@ +// ReSharper disable CheckNamespace + +using System.Globalization; + +namespace EventStore.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; + } + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs b/src/EventStore.Client/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs new file mode 100644 index 000000000..f7d5e4b17 --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/Telemetry/TelemetryTags.cs @@ -0,0 +1,35 @@ +// ReSharper disable CheckNamespace + +namespace EventStore.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"; + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingConstants.cs b/src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingConstants.cs new file mode 100644 index 000000000..26aa2be21 --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingConstants.cs @@ -0,0 +1,10 @@ +// ReSharper disable CheckNamespace + +namespace EventStore.Diagnostics.Tracing; + +static partial class TracingConstants { + public static class Metadata { + public const string TraceId = "$traceId"; + public const string SpanId = "$spanId"; + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingMetadata.cs b/src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingMetadata.cs new file mode 100644 index 000000000..660dcfc0c --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Core/Tracing/TracingMetadata.cs @@ -0,0 +1,33 @@ +// ReSharper disable CheckNamespace + +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace EventStore.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; + } + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/EventMetadataExtensions.cs b/src/EventStore.Client/Common/Diagnostics/EventMetadataExtensions.cs new file mode 100644 index 000000000..19109b538 --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/EventMetadataExtensions.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text.Json; +using EventStore.Diagnostics; +using EventStore.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) { + try { + using var doc = JsonDocument.Parse(eventMetadata); + + return new TracingMetadata( + doc.RootElement.GetProperty(TracingConstants.Metadata.TraceId).GetString(), + doc.RootElement.GetProperty(TracingConstants.Metadata.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; + } + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/EventStoreClientDiagnostics.cs b/src/EventStore.Client/Common/Diagnostics/EventStoreClientDiagnostics.cs new file mode 100644 index 000000000..6328387ab --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/EventStoreClientDiagnostics.cs @@ -0,0 +1,8 @@ +using System.Diagnostics; + +namespace EventStore.Client.Diagnostics; + +public static class EventStoreClientDiagnostics { + public const string InstrumentationName = "eventstoredb"; + public static readonly ActivitySource ActivitySource = new ActivitySource(InstrumentationName); +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Telemetry/TelemetryTags.cs b/src/EventStore.Client/Common/Diagnostics/Telemetry/TelemetryTags.cs new file mode 100644 index 000000000..ea05d04fa --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Telemetry/TelemetryTags.cs @@ -0,0 +1,12 @@ +// ReSharper disable CheckNamespace + +namespace EventStore.Diagnostics.Telemetry; + +static partial class TelemetryTags { + public static class EventStore { + public const string Stream = "db.eventstoredb.stream"; + public const string SubscriptionId = "db.eventstoredb.subscription.id"; + public const string EventId = "db.eventstoredb.event.id"; + public const string EventType = "db.eventstoredb.event.type"; + } +} \ No newline at end of file diff --git a/src/EventStore.Client/Common/Diagnostics/Tracing/TracingConstants.cs b/src/EventStore.Client/Common/Diagnostics/Tracing/TracingConstants.cs new file mode 100644 index 000000000..e43102ebe --- /dev/null +++ b/src/EventStore.Client/Common/Diagnostics/Tracing/TracingConstants.cs @@ -0,0 +1,10 @@ +// ReSharper disable CheckNamespace + +namespace EventStore.Diagnostics.Tracing; + +static partial class TracingConstants { + public static class Operations { + public const string Append = "streams.append"; + public const string Subscribe = "streams.subscribe"; + } +} \ No newline at end of file diff --git a/src/EventStore.Client.Common/EnumerableTaskExtensions.cs b/src/EventStore.Client/Common/EnumerableTaskExtensions.cs similarity index 100% rename from src/EventStore.Client.Common/EnumerableTaskExtensions.cs rename to src/EventStore.Client/Common/EnumerableTaskExtensions.cs diff --git a/src/EventStore.Client.Common/EpochExtensions.cs b/src/EventStore.Client/Common/EpochExtensions.cs similarity index 100% rename from src/EventStore.Client.Common/EpochExtensions.cs rename to src/EventStore.Client/Common/EpochExtensions.cs diff --git a/src/EventStore.Client.Common/EventStoreCallOptions.cs b/src/EventStore.Client/Common/EventStoreCallOptions.cs similarity index 100% rename from src/EventStore.Client.Common/EventStoreCallOptions.cs rename to src/EventStore.Client/Common/EventStoreCallOptions.cs diff --git a/src/EventStore.Client.Common/MetadataExtensions.cs b/src/EventStore.Client/Common/MetadataExtensions.cs similarity index 100% rename from src/EventStore.Client.Common/MetadataExtensions.cs rename to src/EventStore.Client/Common/MetadataExtensions.cs diff --git a/src/EventStore.Client.Common/Shims/Index.cs b/src/EventStore.Client/Common/Shims/Index.cs similarity index 79% rename from src/EventStore.Client.Common/Shims/Index.cs rename to src/EventStore.Client/Common/Shims/Index.cs index 67af4a05d..357bbd34d 100644 --- a/src/EventStore.Client.Common/Shims/Index.cs +++ b/src/EventStore.Client/Common/Shims/Index.cs @@ -11,9 +11,8 @@ namespace System; /// int lastElement = someArray[^1]; // lastElement = 5 /// /// -internal readonly struct Index : IEquatable -{ - private readonly int _value; +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. @@ -22,12 +21,8 @@ namespace System; /// 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"); - } + public Index(int value, bool fromEnd = false) { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); if (fromEnd) _value = ~value; @@ -36,26 +31,19 @@ public Index(int value, bool fromEnd = false) } // The following private constructors mainly created for perf reason to avoid the checks - private Index(int value) - { - _value = value; - } + Index(int value) => _value = value; /// Create an Index pointing at first element. - public static Index Start => new Index(0); + public static Index Start => new(0); /// Create an Index pointing at beyond last element. - public static Index End => new Index(~0); + 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"); - } + public static Index FromStart(int value) { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); return new Index(value); } @@ -63,21 +51,15 @@ public static Index FromStart(int 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"); - } + 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 - { + public int Value { + get { if (_value < 0) return ~_value; else @@ -97,17 +79,14 @@ public int Value /// 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) - { - int offset = _value; + 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; } @@ -126,6 +105,6 @@ public int GetOffset(int length) 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(); + public override string ToString() => IsFromEnd ? $"^{(uint)Value}" : ((uint)Value).ToString(); } -#endif +#endif \ No newline at end of file diff --git a/src/EventStore.Client.Common/Shims/IsExternalInit.cs b/src/EventStore.Client/Common/Shims/IsExternalInit.cs similarity index 84% rename from src/EventStore.Client.Common/Shims/IsExternalInit.cs rename to src/EventStore.Client/Common/Shims/IsExternalInit.cs index a77ccc3c3..7dc4fea3d 100644 --- a/src/EventStore.Client.Common/Shims/IsExternalInit.cs +++ b/src/EventStore.Client/Common/Shims/IsExternalInit.cs @@ -1,9 +1,10 @@ #if !NET + using System.ComponentModel; // ReSharper disable once CheckNamespace namespace System.Runtime.CompilerServices; [EditorBrowsable(EditorBrowsableState.Never)] -internal class IsExternalInit{} +class IsExternalInit{} #endif diff --git a/src/EventStore.Client.Common/Shims/Range.cs b/src/EventStore.Client/Common/Shims/Range.cs similarity index 91% rename from src/EventStore.Client.Common/Shims/Range.cs rename to src/EventStore.Client/Common/Shims/Range.cs index 9d4b88f2f..3a0b34fde 100644 --- a/src/EventStore.Client.Common/Shims/Range.cs +++ b/src/EventStore.Client/Common/Shims/Range.cs @@ -1,4 +1,6 @@ #if !NET +// ReSharper disable CheckNamespace + using System.Runtime.CompilerServices; namespace System; @@ -12,8 +14,7 @@ namespace System; /// int[] subArray2 = someArray[1..^0]; // { 2, 3, 4, 5 } /// /// -internal readonly struct Range : IEquatable -{ +readonly struct Range : IEquatable { /// Represent the inclusive start index of the Range. public Index Start { get; } @@ -23,8 +24,7 @@ namespace System; /// 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) - { + public Range(Index start, Index end) { Start = start; End = end; } @@ -63,17 +63,13 @@ value is Range r && /// We validate the range is inside the length scope though. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public (int Offset, int Length) GetOffsetAndLength(int length) - { - int start = Start.GetOffset(length); - int end = End.GetOffset(length); + 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)); - } + if ((uint)end > (uint)length || (uint)start > (uint)end) throw new ArgumentOutOfRangeException(nameof(length)); return (start, end - start); } } -#endif +#endif \ No newline at end of file diff --git a/src/EventStore.Client/Common/Shims/TaskCompletionSource.cs b/src/EventStore.Client/Common/Shims/TaskCompletionSource.cs new file mode 100644 index 000000000..ad6573c4a --- /dev/null +++ b/src/EventStore.Client/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/EventStore.Client.Common/protos/code.proto b/src/EventStore.Client/Common/protos/code.proto similarity index 100% rename from src/EventStore.Client.Common/protos/code.proto rename to src/EventStore.Client/Common/protos/code.proto diff --git a/src/EventStore.Client.Common/protos/gossip.proto b/src/EventStore.Client/Common/protos/gossip.proto similarity index 100% rename from src/EventStore.Client.Common/protos/gossip.proto rename to src/EventStore.Client/Common/protos/gossip.proto diff --git a/src/EventStore.Client.Common/protos/operations.proto b/src/EventStore.Client/Common/protos/operations.proto similarity index 100% rename from src/EventStore.Client.Common/protos/operations.proto rename to src/EventStore.Client/Common/protos/operations.proto diff --git a/src/EventStore.Client.Common/protos/persistentsubscriptions.proto b/src/EventStore.Client/Common/protos/persistentsubscriptions.proto similarity index 100% rename from src/EventStore.Client.Common/protos/persistentsubscriptions.proto rename to src/EventStore.Client/Common/protos/persistentsubscriptions.proto diff --git a/src/EventStore.Client.Common/protos/projectionmanagement.proto b/src/EventStore.Client/Common/protos/projectionmanagement.proto similarity index 100% rename from src/EventStore.Client.Common/protos/projectionmanagement.proto rename to src/EventStore.Client/Common/protos/projectionmanagement.proto diff --git a/src/EventStore.Client.Common/protos/serverfeatures.proto b/src/EventStore.Client/Common/protos/serverfeatures.proto similarity index 100% rename from src/EventStore.Client.Common/protos/serverfeatures.proto rename to src/EventStore.Client/Common/protos/serverfeatures.proto diff --git a/src/EventStore.Client.Common/protos/shared.proto b/src/EventStore.Client/Common/protos/shared.proto similarity index 100% rename from src/EventStore.Client.Common/protos/shared.proto rename to src/EventStore.Client/Common/protos/shared.proto diff --git a/src/EventStore.Client.Common/protos/status.proto b/src/EventStore.Client/Common/protos/status.proto similarity index 100% rename from src/EventStore.Client.Common/protos/status.proto rename to src/EventStore.Client/Common/protos/status.proto diff --git a/src/EventStore.Client.Common/protos/streams.proto b/src/EventStore.Client/Common/protos/streams.proto similarity index 100% rename from src/EventStore.Client.Common/protos/streams.proto rename to src/EventStore.Client/Common/protos/streams.proto diff --git a/src/EventStore.Client.Common/protos/usermanagement.proto b/src/EventStore.Client/Common/protos/usermanagement.proto similarity index 100% rename from src/EventStore.Client.Common/protos/usermanagement.proto rename to src/EventStore.Client/Common/protos/usermanagement.proto diff --git a/src/EventStore.Client/EventStore.Client.csproj b/src/EventStore.Client/EventStore.Client.csproj index 6a2aa522c..9855a1fbf 100644 --- a/src/EventStore.Client/EventStore.Client.csproj +++ b/src/EventStore.Client/EventStore.Client.csproj @@ -5,34 +5,36 @@ The base GRPC client library for Event Store. Get the open source or commercial versions of Event Store server from https://eventstore.com/ EventStore.Client.Grpc + + + + - - - - - - - - - - - + + + + + - - + + - + - + diff --git a/src/EventStore.Client/EventStore.Client.csproj.DotSettings b/src/EventStore.Client/EventStore.Client.csproj.DotSettings new file mode 100644 index 000000000..9b89548c6 --- /dev/null +++ b/src/EventStore.Client/EventStore.Client.csproj.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/src/EventStore.Client/EventStoreClientBase.cs b/src/EventStore.Client/EventStoreClientBase.cs index 39f579fcc..2b4a5b2f6 100644 --- a/src/EventStore.Client/EventStoreClientBase.cs +++ b/src/EventStore.Client/EventStoreClientBase.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using EventStore.Client.Interceptors; using Grpc.Core; using Grpc.Core.Interceptors; +using Enum = System.Enum; namespace EventStore.Client { /// @@ -13,28 +10,29 @@ namespace EventStore.Client { public abstract class EventStoreClientBase : 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 Dictionary> _exceptionMap; + private readonly CancellationTokenSource _cts; + private readonly ChannelCache _channelCache; private readonly SharingProvider _channelInfoProvider; - private readonly Lazy _httpFallback; - + private readonly Lazy _httpFallback; + /// The name of the connection. public string ConnectionName { get; } - + /// The . protected EventStoreClientSettings Settings { get; } /// Constructs a new . - protected EventStoreClientBase(EventStoreClientSettings? settings, - Dictionary> exceptionMap) { - Settings = settings ?? new EventStoreClientSettings(); + protected EventStoreClientBase( + EventStoreClientSettings? settings, + Dictionary> exceptionMap + ) { + Settings = settings ?? new EventStoreClientSettings(); _exceptionMap = exceptionMap; - _cts = new CancellationTokenSource(); + _cts = new CancellationTokenSource(); _channelCache = new(Settings); _httpFallback = new Lazy(() => new HttpFallback(Settings)); - + ConnectionName = Settings.ConnectionName ?? $"ES-{Guid.NewGuid()}"; var channelSelector = new ChannelSelector(Settings, _channelCache); @@ -43,17 +41,18 @@ protected EventStoreClientBase(EventStoreClientSettings? settings, GetChannelInfoExpensive(endPoint, onBroken, channelSelector, _cts.Token), factoryRetryDelay: Settings.ConnectivitySettings.DiscoveryInterval, initialInput: ReconnectionRequired.Rediscover.Instance, - loggerFactory: Settings.LoggerFactory); + 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) { - + CancellationToken cancellationToken + ) { var channel = reconnectionRequired switch { ReconnectionRequired.Rediscover => await channelSelector.SelectChannelAsync(cancellationToken) .ConfigureAwait(false), @@ -78,11 +77,10 @@ private async Task GetChannelInfoExpensive( 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 @@ -95,20 +93,30 @@ internal Task RediscoverAsync() { } /// 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) { - + 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) { - + 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) + .HttpPostAsync( + path, + query, + channelInfo, + deadline, + userCredentials, + onNotFound, + cancellationToken + ) .ConfigureAwait(false); } @@ -118,7 +126,7 @@ public virtual void Dispose() { _cts.Cancel(); _cts.Dispose(); _channelCache.Dispose(); - + if (_httpFallback.IsValueCreated) { _httpFallback.Value.Dispose(); } diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 0357513c2..151f65d09 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -35,6 +35,6 @@ - + diff --git a/test/EventStore.Client.PersistentSubscriptions.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs b/test/EventStore.Client.PersistentSubscriptions.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs new file mode 100644 index 000000000..9a2e4ec10 --- /dev/null +++ b/test/EventStore.Client.PersistentSubscriptions.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs @@ -0,0 +1,74 @@ +using EventStore.Diagnostics.Tracing; + +namespace EventStore.Client.PersistentSubscriptions.Tests.Diagnostics; + +[Trait("Category", "Diagnostics:Tracing")] +public class PersistentSubscriptionsTracingInstrumentationTests(ITestOutputHelper output, DiagnosticsFixture fixture) + : EventStoreTests(output, fixture) { + [Fact] + public async Task PersistentSubscriptionIsInstrumentedWithTracingAndRestoresRemoteAppendContextAsExpected() { + var stream = Fixture.GetStreamName(); + var events = Fixture.CreateTestEvents(2, metadata: Fixture.CreateTestJsonMetadata()).ToArray(); + + var groupName = $"{stream}-group"; + await Fixture.Subscriptions.CreateToStreamAsync( + stream, + groupName, + new() + ); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + events + ); + + string? subscriptionId = null; + await Subscribe().WithTimeout(); + + var appendActivity = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Append, stream) + .SingleOrDefault() + .ShouldNotBeNull(); + + var subscribeActivities = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Subscribe, stream) + .ToArray(); + + subscriptionId.ShouldNotBeNull(); + subscribeActivities.Length.ShouldBe(events.Length); + + for (var i = 0; i < subscribeActivities.Length; i++) { + subscribeActivities[i].TraceId.ShouldBe(appendActivity.Context.TraceId); + subscribeActivities[i].ParentSpanId.ShouldBe(appendActivity.Context.SpanId); + subscribeActivities[i].HasRemoteParent.ShouldBeTrue(); + + Fixture.AssertSubscriptionActivityHasExpectedTags( + subscribeActivities[i], + stream, + events[i].EventId.ToString(), + subscriptionId + ); + } + + return; + + async Task Subscribe() { + await using var subscription = Fixture.Subscriptions.SubscribeToStream(stream, groupName); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + int eventsAppeared = 0; + while (await enumerator.MoveNextAsync()) { + if (enumerator.Current is PersistentSubscriptionMessage.SubscriptionConfirmation(var sid)) + subscriptionId = sid; + + if (enumerator.Current is not PersistentSubscriptionMessage.Event(_, _)) + continue; + + eventsAppeared++; + if (eventsAppeared >= events.Length) + return; + } + } + } +} \ No newline at end of file diff --git a/test/EventStore.Client.Streams.Tests/Append/append_to_stream.cs b/test/EventStore.Client.Streams.Tests/Append/append_to_stream.cs index 989a6b6bf..6cd5b813d 100644 --- a/test/EventStore.Client.Streams.Tests/Append/append_to_stream.cs +++ b/test/EventStore.Client.Streams.Tests/Append/append_to_stream.cs @@ -5,7 +5,8 @@ namespace EventStore.Client.Streams.Tests.Append; [Trait("Category", "Target:Stream")] [Trait("Category", "Operation:Append")] -public class append_to_stream(ITestOutputHelper output, EventStoreFixture fixture) : EventStoreTests(output, fixture) { +public class append_to_stream(ITestOutputHelper output, EventStoreFixture fixture) + : EventStoreTests(output, fixture) { public static IEnumerable ExpectedVersionCreateStreamTestCases() { yield return new object?[] { StreamState.Any }; yield return new object?[] { StreamState.NoStream }; @@ -18,7 +19,12 @@ public async Task appending_zero_events(StreamState expectedStreamState) { const int iterations = 2; for (var i = 0; i < iterations; i++) { - var writeResult = await Fixture.Streams.AppendToStreamAsync(stream, expectedStreamState, Enumerable.Empty()); + var writeResult = await Fixture.Streams.AppendToStreamAsync( + stream, + expectedStreamState, + Enumerable.Empty() + ); + writeResult.NextExpectedStreamRevision.ShouldBe(StreamRevision.None); } @@ -34,7 +40,12 @@ public async Task appending_zero_events_again(StreamState expectedStreamState) { const int iterations = 2; for (var i = 0; i < iterations; i++) { - var writeResult = await Fixture.Streams.AppendToStreamAsync(stream, expectedStreamState, Enumerable.Empty()); + var writeResult = await Fixture.Streams.AppendToStreamAsync( + stream, + expectedStreamState, + Enumerable.Empty() + ); + Assert.Equal(StreamRevision.None, writeResult.NextExpectedStreamRevision); } @@ -87,7 +98,8 @@ public async Task multiple_idempotent_writes_with_same_id_bug_case() { } [Fact] - public async Task in_case_where_multiple_writes_of_multiple_events_with_the_same_ids_using_expected_version_any_then_next_expected_version_is_unreliable() { + public async Task + in_case_where_multiple_writes_of_multiple_events_with_the_same_ids_using_expected_version_any_then_next_expected_version_is_unreliable() { var stream = Fixture.GetStreamName(); var evnt = Fixture.CreateTestEvents().First(); @@ -103,7 +115,8 @@ public async Task in_case_where_multiple_writes_of_multiple_events_with_the_same } [Fact] - public async Task in_case_where_multiple_writes_of_multiple_events_with_the_same_ids_using_expected_version_nostream_then_next_expected_version_is_correct() { + public async Task + in_case_where_multiple_writes_of_multiple_events_with_the_same_ids_using_expected_version_nostream_then_next_expected_version_is_correct() { var stream = Fixture.GetStreamName(); var evnt = Fixture.CreateTestEvents().First(); @@ -280,18 +293,20 @@ await Fixture.Streams.AppendToStreamAsync( } [Fact] - public async Task appending_with_stream_exists_expected_version_and_stream_does_not_exist_throws_wrong_expected_version() { + public async Task + appending_with_stream_exists_expected_version_and_stream_does_not_exist_throws_wrong_expected_version() { var stream = Fixture.GetStreamName(); var ex = await Fixture.Streams .AppendToStreamAsync(stream, StreamState.StreamExists, Fixture.CreateTestEvents()) .ShouldThrowAsync(); - + ex.ActualStreamRevision.ShouldBe(StreamRevision.None); } [Fact] - public async Task appending_with_stream_exists_expected_version_and_stream_does_not_exist_returns_wrong_expected_version() { + public async Task + appending_with_stream_exists_expected_version_and_stream_does_not_exist_returns_wrong_expected_version() { var stream = Fixture.GetStreamName(); var writeResult = await Fixture.Streams.AppendToStreamAsync( @@ -334,7 +349,11 @@ await Fixture.Streams public async Task can_append_multiple_events_at_once() { var stream = Fixture.GetStreamName(); - var writeResult = await Fixture.Streams.AppendToStreamAsync(stream, StreamState.NoStream, Fixture.CreateTestEvents(100)); + var writeResult = await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents(100) + ); Assert.Equal(new(99), writeResult.NextExpectedStreamRevision); } @@ -387,7 +406,7 @@ public async Task returns_failure_status_when_conditionally_appending_to_a_delet Assert.Equal(ConditionalWriteResult.StreamDeleted, result); } - + [Fact] public async Task expected_version_no_stream() { var result = await Fixture.Streams.AppendToStreamAsync( @@ -409,7 +428,7 @@ public async Task expected_version_no_stream_returns_position() { Assert.True(result.LogPosition > Position.Start); } - + [Fact] public async Task with_timeout_any_stream_revision_fails_when_operation_expired() { var stream = Fixture.GetStreamName(); @@ -429,7 +448,7 @@ public async Task with_timeout_stream_revision_fails_when_operation_expired() { var stream = Fixture.GetStreamName(); await Fixture.Streams.AppendToStreamAsync(stream, StreamState.Any, Fixture.CreateTestEvents()); - + var ex = await Fixture.Streams.AppendToStreamAsync( stream, new StreamRevision(0), @@ -448,32 +467,14 @@ await Fixture.Streams .AppendToStreamAsync( streamName, StreamRevision.None, - GetEvents(), + Fixture.CreateTestEventsThatThrowsException(), userCredentials: new UserCredentials(TestCredentials.Root.Username!, TestCredentials.Root.Password!) ) - .ShouldThrowAsync(); + .ShouldThrowAsync(); var state = await Fixture.Streams.ReadStreamAsync(Direction.Forwards, streamName, StreamPosition.Start) .ReadState; state.ShouldBe(ReadState.StreamNotFound); - - return; - - IEnumerable GetEvents() { - for (var i = 0; i < 5; i++) { - if (i % 3 == 0) - throw new EnumerationFailedException(); - - yield return Fixture.CreateTestEvents(1).First(); - } - } - } - - class EnumerationFailedException : Exception { } - - public static IEnumerable ArgumentOutOfRangeTestCases() { - yield return new object?[] { StreamState.Any }; - yield return new object?[] { ulong.MaxValue - 1UL }; } } diff --git a/test/EventStore.Client.Streams.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs b/test/EventStore.Client.Streams.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs new file mode 100644 index 000000000..bd60c279c --- /dev/null +++ b/test/EventStore.Client.Streams.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs @@ -0,0 +1,148 @@ +using EventStore.Client.Diagnostics; +using EventStore.Diagnostics.Tracing; + +namespace EventStore.Client.Streams.Tests.Diagnostics; + +[Trait("Category", "Diagnostics:Tracing")] +public class StreamsTracingInstrumentationTests(ITestOutputHelper output, DiagnosticsFixture fixture) : EventStoreTests(output, fixture) { + [Fact] + public async Task AppendIsInstrumentedWithTracingAsExpected() { + var stream = Fixture.GetStreamName(); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents() + ); + + var activity = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Append, stream) + .SingleOrDefault() + .ShouldNotBeNull(); + + Fixture.AssertAppendActivityHasExpectedTags(activity, stream); + } + + [Fact] + public async Task AppendTraceIsTaggedWithErrorStatusOnException() { + var stream = Fixture.GetStreamName(); + + var actualException = await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEventsThatThrowsException() + ).ShouldThrowAsync(); + + var activity = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Append, stream) + .SingleOrDefault() + .ShouldNotBeNull(); + + Fixture.AssertErroneousAppendActivityHasExpectedTags(activity, actualException); + } + + [Fact] + public async Task CatchupSubscriptionIsInstrumentedWithTracingAndRestoresRemoteAppendContextAsExpected() { + var stream = Fixture.GetStreamName(); + var events = Fixture.CreateTestEvents(2, metadata: Fixture.CreateTestJsonMetadata()).ToArray(); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + events + ); + + string? subscriptionId = null; + await Subscribe().WithTimeout(); + + var appendActivity = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Append, stream) + .SingleOrDefault() + .ShouldNotBeNull(); + + var subscribeActivities = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Subscribe, stream) + .ToArray(); + + subscriptionId.ShouldNotBeNull(); + subscribeActivities.Length.ShouldBe(events.Length); + + for (var i = 0; i < subscribeActivities.Length; i++) { + subscribeActivities[i].TraceId.ShouldBe(appendActivity.Context.TraceId); + subscribeActivities[i].ParentSpanId.ShouldBe(appendActivity.Context.SpanId); + subscribeActivities[i].HasRemoteParent.ShouldBeTrue(); + + Fixture.AssertSubscriptionActivityHasExpectedTags( + subscribeActivities[i], + stream, + events[i].EventId.ToString(), + subscriptionId + ); + } + + return; + + async Task Subscribe() { + await using var subscription = Fixture.Streams.SubscribeToStream(stream, FromStream.Start); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + var eventsAppeared = 0; + while (await enumerator.MoveNextAsync()) { + if (enumerator.Current is StreamMessage.SubscriptionConfirmation(var sid)) + subscriptionId = sid; + + if (enumerator.Current is not StreamMessage.Event(_)) + continue; + + eventsAppeared++; + if (eventsAppeared >= events.Length) + return; + } + } + } + + [Fact] + public async Task TracingContextIsInjectedWhenUserMetadataIsValidJsonObject() { + var stream = Fixture.GetStreamName(); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents(1, metadata: Fixture.CreateTestJsonMetadata()) + ); + + var activity = Fixture + .GetActivitiesForOperation(TracingConstants.Operations.Append, stream) + .SingleOrDefault() + .ShouldNotBeNull(); + + var readResult = await Fixture.Streams + .ReadStreamAsync(Direction.Forwards, stream, StreamPosition.Start) + .ToListAsync(); + + var tracingMetadata = readResult[0].OriginalEvent.Metadata.ExtractTracingMetadata(); + + tracingMetadata.ShouldNotBe(TracingMetadata.None); + tracingMetadata.TraceId.ShouldBe(activity.TraceId.ToString()); + tracingMetadata.SpanId.ShouldBe(activity.SpanId.ToString()); + } + + [Fact] + public async Task TracingContextIsNotInjectedWhenUserMetadataIsNotValidJsonObject() { + var stream = Fixture.GetStreamName(); + + var inputMetadata = "clearlynotavalidjsonobject"u8.ToArray(); + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents(1, metadata: inputMetadata) + ); + + var readResult = await Fixture.Streams + .ReadStreamAsync(Direction.Forwards, stream, StreamPosition.Start) + .ToListAsync(); + + var outputMetadata = readResult[0].OriginalEvent.Metadata.ToArray(); + outputMetadata.ShouldBe(inputMetadata); + } +} \ No newline at end of file diff --git a/test/EventStore.Client.Streams.Tests/Read/ReadAllEventsFixture.cs b/test/EventStore.Client.Streams.Tests/Read/ReadAllEventsFixture.cs index 521f486ab..197c8a481 100644 --- a/test/EventStore.Client.Streams.Tests/Read/ReadAllEventsFixture.cs +++ b/test/EventStore.Client.Streams.Tests/Read/ReadAllEventsFixture.cs @@ -9,14 +9,14 @@ public ReadAllEventsFixture() { new(acl: new(SystemRoles.All)), userCredentials: TestCredentials.Root ); - + Events = CreateTestEvents(20) - .Concat(CreateTestEvents(2, metadataSize: 1_000_000)) + .Concat(CreateTestEvents(2, metadata: CreateMetadataOfSize(1_000_000))) .Concat(CreateTestEvents(2, AnotherTestEventType)) .ToArray(); ExpectedStreamName = GetStreamName(); - + await Streams.AppendToStreamAsync(ExpectedStreamName, StreamState.NoStream, Events); ExpectedEvents = Events.ToBinaryData(); @@ -28,12 +28,12 @@ public ReadAllEventsFixture() { } public string ExpectedStreamName { get; private set; } = null!; - + public EventData[] Events { get; private set; } = Array.Empty(); - + public EventBinaryData[] ExpectedEvents { get; private set; } = Array.Empty(); public EventBinaryData[] ExpectedEventsReversed { get; private set; } = Array.Empty(); public EventBinaryData ExpectedFirstEvent { get; private set; } public EventBinaryData ExpectedLastEvent { get; private set; } -} +} \ No newline at end of file diff --git a/test/EventStore.Client.Streams.Tests/Read/read_stream_backward.cs b/test/EventStore.Client.Streams.Tests/Read/read_stream_backward.cs index 4101ef3b6..ec32888e8 100644 --- a/test/EventStore.Client.Streams.Tests/Read/read_stream_backward.cs +++ b/test/EventStore.Client.Streams.Tests/Read/read_stream_backward.cs @@ -64,7 +64,7 @@ public async Task stream_deleted_throws() { public async Task returns_events_in_reversed_order(string suffix, int count, int metadataSize) { var stream = $"{Fixture.GetStreamName()}_{suffix}"; - var expected = Fixture.CreateTestEvents(count, metadataSize: metadataSize).ToArray(); + var expected = Fixture.CreateTestEvents(count, metadata: Fixture.CreateMetadataOfSize(metadataSize)).ToArray(); await Fixture.Streams.AppendToStreamAsync(stream, StreamState.NoStream, expected); diff --git a/test/EventStore.Client.Streams.Tests/Read/read_stream_forward.cs b/test/EventStore.Client.Streams.Tests/Read/read_stream_forward.cs index b8571b121..14c08a79c 100644 --- a/test/EventStore.Client.Streams.Tests/Read/read_stream_forward.cs +++ b/test/EventStore.Client.Streams.Tests/Read/read_stream_forward.cs @@ -62,7 +62,7 @@ public async Task stream_deleted_throws() { public async Task returns_events_in_order(string suffix, int count, int metadataSize) { var stream = $"{Fixture.GetStreamName()}_{suffix}"; - var expected = Fixture.CreateTestEvents(count, metadataSize: metadataSize).ToArray(); + var expected = Fixture.CreateTestEvents(count, metadata: Fixture.CreateMetadataOfSize(metadataSize)).ToArray(); await Fixture.Streams.AppendToStreamAsync(stream, StreamState.NoStream, expected); diff --git a/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_all.cs b/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_all.cs index f0f7cb9dc..2e9879a4a 100644 --- a/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_all.cs +++ b/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_all.cs @@ -7,24 +7,30 @@ public class subscribe_to_all(ITestOutputHelper output, SubscriptionsFixture fix [Fact] public async Task receives_all_events_from_start() { var seedEvents = Fixture.CreateTestEvents(10).ToArray(); - var pageSize = seedEvents.Length / 2; + var pageSize = seedEvents.Length / 2; var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); foreach (var evt in seedEvents.Take(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"stream-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"stream-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); await using var subscription = Fixture.Streams.SubscribeToAll(FromAll.Start); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); Assert.IsType(enumerator.Current); foreach (var evt in seedEvents.Skip(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"stream-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"stream-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); await Subscribe().WithTimeout(); @@ -52,7 +58,7 @@ public async Task receives_all_events_from_end() { var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); await using var subscription = Fixture.Streams.SubscribeToAll(FromAll.End); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -60,8 +66,11 @@ public async Task receives_all_events_from_end() { // add the events we want to receive after we start the subscription foreach (var evt in seedEvents) - await Fixture.Streams.AppendToStreamAsync($"stream-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"stream-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); await Subscribe().WithTimeout(); @@ -85,28 +94,34 @@ async Task Subscribe() { [Fact] public async Task receives_all_events_from_position() { var seedEvents = Fixture.CreateTestEvents(10).ToArray(); - var pageSize = seedEvents.Length / 2; + var pageSize = seedEvents.Length / 2; // only the second half of the events will be received var availableEvents = new HashSet(seedEvents.Skip(pageSize).Select(x => x.EventId)); IWriteResult writeResult = new SuccessResult(); foreach (var evt in seedEvents.Take(pageSize)) - writeResult = await Fixture.Streams.AppendToStreamAsync($"stream-{evt.EventId.ToGuid():N}", - StreamState.NoStream, new[] { evt }); + writeResult = await Fixture.Streams.AppendToStreamAsync( + $"stream-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); var position = FromAll.After(writeResult.LogPosition); await using var subscription = Fixture.Streams.SubscribeToAll(position); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); Assert.IsType(enumerator.Current); foreach (var evt in seedEvents.Skip(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"stream-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"stream-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); await Subscribe().WithTimeout(); @@ -131,13 +146,13 @@ async Task Subscribe() { public async Task receives_all_events_with_resolved_links() { var streamName = Fixture.GetStreamName(); - var seedEvents = Fixture.CreateTestEvents(3).ToArray(); + var seedEvents = Fixture.CreateTestEvents(3).ToArray(); var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents); await using var subscription = Fixture.Streams.SubscribeToAll(FromAll.Start, true); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -178,11 +193,15 @@ public async Task receives_all_filtered_events_from_start(SubscriptionFilter fil var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); // add noise - await Fixture.Streams.AppendToStreamAsync(Fixture.GetStreamName(), StreamState.NoStream, - Fixture.CreateTestEvents(3)); + await Fixture.Streams.AppendToStreamAsync( + Fixture.GetStreamName(), + StreamState.NoStream, + Fixture.CreateTestEvents(3) + ); var existingEventsCount = await Fixture.Streams.ReadAllAsync(Direction.Forwards, Position.Start) .Messages.CountAsync(); + Fixture.Log.Debug("Existing events count: {ExistingEventsCount}", existingEventsCount); // Debugging: @@ -191,13 +210,16 @@ await Fixture.Streams.AppendToStreamAsync(Fixture.GetStreamName(), StreamState.N // add some of the events we want to see before we start the subscription foreach (var evt in seedEvents.Take(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"{streamPrefix}-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); var filterOptions = new SubscriptionFilterOptions(filter.Create(streamPrefix), 1); await using var subscription = Fixture.Streams.SubscribeToAll(FromAll.Start, filterOptions: filterOptions); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -205,8 +227,11 @@ await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid() // add some of the events we want to see after we start the subscription foreach (var evt in seedEvents.Skip(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"{streamPrefix}-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); bool checkpointReached = false; @@ -223,6 +248,7 @@ async Task Subscribe() { checkpointReached = true; break; + case StreamMessage.Event(var resolvedEvent): { availableEvents.Remove(resolvedEvent.Event.EventId); @@ -254,11 +280,15 @@ public async Task receives_all_filtered_events_from_end(SubscriptionFilter filte var availableEvents = new HashSet(seedEvents.Skip(pageSize).Select(x => x.EventId)); // add noise - await Fixture.Streams.AppendToStreamAsync(Fixture.GetStreamName(), StreamState.NoStream, - Fixture.CreateTestEvents(3)); + await Fixture.Streams.AppendToStreamAsync( + Fixture.GetStreamName(), + StreamState.NoStream, + Fixture.CreateTestEvents(3) + ); var existingEventsCount = await Fixture.Streams.ReadAllAsync(Direction.Forwards, Position.Start) .Messages.CountAsync(); + Fixture.Log.Debug("Existing events count: {ExistingEventsCount}", existingEventsCount); // Debugging: @@ -267,13 +297,16 @@ await Fixture.Streams.AppendToStreamAsync(Fixture.GetStreamName(), StreamState.N // add some of the events we want to see before we start the subscription foreach (var evt in seedEvents.Take(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"{streamPrefix}-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); var filterOptions = new SubscriptionFilterOptions(filter.Create(streamPrefix), 1); await using var subscription = Fixture.Streams.SubscribeToAll(FromAll.End, filterOptions: filterOptions); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -281,8 +314,11 @@ await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid() // add some of the events we want to see after we start the subscription foreach (var evt in seedEvents.Skip(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"{streamPrefix}-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); bool checkpointReached = false; @@ -299,6 +335,7 @@ async Task Subscribe() { checkpointReached = true; break; + case StreamMessage.Event(var resolvedEvent): { availableEvents.Remove(resolvedEvent.Event.EventId); @@ -330,25 +367,32 @@ public async Task receives_all_filtered_events_from_position(SubscriptionFilter var availableEvents = new HashSet(seedEvents.Skip(pageSize).Select(x => x.EventId)); // add noise - await Fixture.Streams.AppendToStreamAsync(Fixture.GetStreamName(), StreamState.NoStream, - Fixture.CreateTestEvents(3)); + await Fixture.Streams.AppendToStreamAsync( + Fixture.GetStreamName(), + StreamState.NoStream, + Fixture.CreateTestEvents(3) + ); var existingEventsCount = await Fixture.Streams.ReadAllAsync(Direction.Forwards, Position.Start) .Messages.CountAsync(); + Fixture.Log.Debug("Existing events count: {ExistingEventsCount}", existingEventsCount); // add some of the events that are a match to the filter but will not be received IWriteResult writeResult = new SuccessResult(); foreach (var evt in seedEvents.Take(pageSize)) - writeResult = await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid():N}", - StreamState.NoStream, new[] { evt }); + writeResult = await Fixture.Streams.AppendToStreamAsync( + $"{streamPrefix}-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); var position = FromAll.After(writeResult.LogPosition); var filterOptions = new SubscriptionFilterOptions(filter.Create(streamPrefix), 1); await using var subscription = Fixture.Streams.SubscribeToAll(position, filterOptions: filterOptions); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -356,8 +400,11 @@ await Fixture.Streams.AppendToStreamAsync(Fixture.GetStreamName(), StreamState.N // add the events we want to receive after we start the subscription foreach (var evt in seedEvents.Skip(pageSize)) - await Fixture.Streams.AppendToStreamAsync($"{streamPrefix}-{evt.EventId.ToGuid():N}", StreamState.NoStream, - new[] { evt }); + await Fixture.Streams.AppendToStreamAsync( + $"{streamPrefix}-{evt.EventId.ToGuid():N}", + StreamState.NoStream, + new[] { evt } + ); bool checkpointReached = false; @@ -374,6 +421,7 @@ async Task Subscribe() { checkpointReached = true; break; + case StreamMessage.Event(var resolvedEvent): { availableEvents.Remove(resolvedEvent.Event.EventId); @@ -392,7 +440,7 @@ async Task Subscribe() { public async Task receives_all_filtered_events_with_resolved_links() { var streamName = Fixture.GetStreamName(); - var seedEvents = Fixture.CreateTestEvents(3).ToArray(); + var seedEvents = Fixture.CreateTestEvents(3).ToArray(); var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents); @@ -403,6 +451,7 @@ public async Task receives_all_filtered_events_with_resolved_links() { await using var subscription = Fixture.Streams.SubscribeToAll(FromAll.Start, true, filterOptions: filterOptions); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); diff --git a/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_stream.cs b/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_stream.cs index 3cc26c8a1..d38391e8f 100644 --- a/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_stream.cs +++ b/test/EventStore.Client.Streams.Tests/Subscriptions/subscribe_to_stream.cs @@ -9,14 +9,14 @@ public async Task receives_all_events_from_start() { var streamName = Fixture.GetStreamName(); var seedEvents = Fixture.CreateTestEvents(10).ToArray(); - var pageSize = seedEvents.Length / 2; + var pageSize = seedEvents.Length / 2; var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents.Take(pageSize)); await using var subscription = Fixture.Streams.SubscribeToStream(streamName, FromStream.Start); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -48,25 +48,29 @@ public async Task receives_all_events_from_position() { var streamName = Fixture.GetStreamName(); var seedEvents = Fixture.CreateTestEvents(10).ToArray(); - var pageSize = seedEvents.Length / 2; + var pageSize = seedEvents.Length / 2; // only the second half of the events will be received var availableEvents = new HashSet(seedEvents.Skip(pageSize).Select(x => x.EventId)); var writeResult = await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents.Take(pageSize)); + var streamPosition = StreamPosition.FromStreamRevision(writeResult.NextExpectedStreamRevision); - var checkpoint = FromStream.After(streamPosition); + var checkpoint = FromStream.After(streamPosition); await using var subscription = Fixture.Streams.SubscribeToStream(streamName, checkpoint); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); Assert.IsType(enumerator.Current); - await Fixture.Streams.AppendToStreamAsync(streamName, writeResult.NextExpectedStreamRevision, - seedEvents.Skip(pageSize)); + await Fixture.Streams.AppendToStreamAsync( + streamName, + writeResult.NextExpectedStreamRevision, + seedEvents.Skip(pageSize) + ); await Subscribe().WithTimeout(); @@ -96,7 +100,7 @@ public async Task receives_all_events_from_non_existing_stream() { var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); await using var subscription = Fixture.Streams.SubscribeToStream(streamName, FromStream.Start); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -132,14 +136,14 @@ public async Task allow_multiple_subscriptions_to_same_stream() { await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents); await using var subscription1 = Fixture.Streams.SubscribeToStream(streamName, FromStream.Start); - await using var enumerator1 = subscription1.Messages.GetAsyncEnumerator(); + await using var enumerator1 = subscription1.Messages.GetAsyncEnumerator(); Assert.True(await enumerator1.MoveNextAsync()); Assert.IsType(enumerator1.Current); await using var subscription2 = Fixture.Streams.SubscribeToStream(streamName, FromStream.Start); - await using var enumerator2 = subscription2.Messages.GetAsyncEnumerator(); + await using var enumerator2 = subscription2.Messages.GetAsyncEnumerator(); Assert.True(await enumerator2.MoveNextAsync()); @@ -171,7 +175,7 @@ public async Task drops_when_stream_tombstoned() { var streamName = Fixture.GetStreamName(); await using var subscription = Fixture.Streams.SubscribeToStream(streamName, FromStream.Start); - await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); @@ -180,9 +184,11 @@ public async Task drops_when_stream_tombstoned() { // rest in peace await Fixture.Streams.TombstoneAsync(streamName, StreamState.NoStream); - var ex = await Assert.ThrowsAsync(async () => { - while (await enumerator.MoveNextAsync()) { } - }).WithTimeout(); + var ex = await Assert.ThrowsAsync( + async () => { + while (await enumerator.MoveNextAsync()) { } + } + ).WithTimeout(); ex.ShouldBeOfType().Stream.ShouldBe(streamName); } @@ -191,13 +197,14 @@ public async Task drops_when_stream_tombstoned() { public async Task receives_all_events_with_resolved_links() { var streamName = Fixture.GetStreamName(); - var seedEvents = Fixture.CreateTestEvents(3).ToArray(); + var seedEvents = Fixture.CreateTestEvents(3).ToArray(); var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents); await using var subscription = Fixture.Streams.SubscribeToStream($"$et-{EventStoreFixture.TestEventType}", FromStream.Start, true); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync()); diff --git a/test/EventStore.Client.Tests.Common/Fixtures/Base/EventStoreTestServer.cs b/test/EventStore.Client.Tests.Common/Fixtures/Base/EventStoreTestServer.cs index c1edde5a3..20c8c0c93 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/Base/EventStoreTestServer.cs +++ b/test/EventStore.Client.Tests.Common/Fixtures/Base/EventStoreTestServer.cs @@ -42,7 +42,7 @@ public EventStoreTestServer( var env = new Dictionary { ["EVENTSTORE_DB_LOG_FORMAT"] = "V2", ["EVENTSTORE_MEM_DB"] = "true", - ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024).ToString(), + ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024 * 1024).ToString(), ["EVENTSTORE_CERTIFICATE_FILE"] = "/etc/eventstore/certs/node/node.crt", ["EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE"] = "/etc/eventstore/certs/node/node.key", ["EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH"] = "/etc/eventstore/certs/ca", diff --git a/test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs b/test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs index cc2bf83ac..6b57137cc 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs +++ b/test/EventStore.Client.Tests.Common/Fixtures/CertificatesManager.cs @@ -55,7 +55,7 @@ await GenerateCertificates( static Task GenerateCertificates(string sourceFolder, string expectedLogMessage, string command, params string[] commandArgs) { using var container = new Builder() .UseContainer() - .UseImage("ghcr.io/eventstore/es-gencert-cli:1.3") + .UseImage("ghcr.io/eventstore/es-gencert-cli:1.3.0") .MountVolume(sourceFolder, "/tmp", Ductus.FluentDocker.Model.Builders.MountType.ReadWrite) // .MountVolume(Options.CertificateDirectory.FullName, "/etc/eventstore/certs", MountType.ReadOnly) .Command(command, commandArgs) diff --git a/test/EventStore.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs b/test/EventStore.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs new file mode 100644 index 000000000..3eed0f25b --- /dev/null +++ b/test/EventStore.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using EventStore.Client.Diagnostics; +using EventStore.Diagnostics; +using EventStore.Diagnostics.Telemetry; +using EventStore.Diagnostics.Tracing; + +namespace EventStore.Client.Tests; + +[PublicAPI] +public class DiagnosticsFixture : EventStoreFixture { + readonly ConcurrentDictionary<(string Operation, string Stream), List> _activities = []; + + public DiagnosticsFixture() { + var diagnosticActivityListener = new ActivityListener { + ShouldListenTo = source => source.Name == EventStoreClientDiagnostics.InstrumentationName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => { + var operation = (string?)activity.GetTagItem(TelemetryTags.Database.Operation); + var stream = (string?)activity.GetTagItem(TelemetryTags.EventStore.Stream); + + if (operation is null || stream is null) + return; + + _activities.AddOrUpdate( + (operation, stream), + _ => [activity], + (_, activities) => { + activities.Add(activity); + return activities; + } + ); + } + }; + + OnSetup = () => { + ActivitySource.AddActivityListener(diagnosticActivityListener); + return Task.CompletedTask; + }; + + OnTearDown = () => { + diagnosticActivityListener.Dispose(); + return Task.CompletedTask; + }; + } + + public List GetActivitiesForOperation(string operation, string stream) => + _activities.TryGetValue((operation, stream), out var activities) ? activities : []; + + public void AssertAppendActivityHasExpectedTags(Activity activity, string stream) { + var expectedTags = new Dictionary { + { TelemetryTags.Database.System, EventStoreClientDiagnostics.InstrumentationName }, + { TelemetryTags.Database.Operation, TracingConstants.Operations.Append }, + { TelemetryTags.EventStore.Stream, stream }, + { TelemetryTags.Database.User, TestCredentials.Root.Username }, + { TelemetryTags.Otel.StatusCode, ActivityStatusCodeHelper.OkStatusCodeTagValue } + }; + + foreach (var tag in expectedTags) + activity.Tags.ShouldContain(tag); + } + + public void AssertErroneousAppendActivityHasExpectedTags(Activity activity, Exception actualException) { + var expectedTags = new Dictionary { + { TelemetryTags.Otel.StatusCode, ActivityStatusCodeHelper.ErrorStatusCodeTagValue } + }; + + foreach (var tag in expectedTags) + activity.Tags.ShouldContain(tag); + + var actualEvent = activity.Events.ShouldHaveSingleItem(); + + actualEvent.Name.ShouldBe(TelemetryTags.Exception.EventName); + actualEvent.Tags.ShouldContain(new KeyValuePair(TelemetryTags.Exception.Type, actualException.GetType().FullName)); + actualEvent.Tags.ShouldContain(new KeyValuePair(TelemetryTags.Exception.Message, actualException.Message)); + actualEvent.Tags.Any(x => x.Key == TelemetryTags.Exception.Stacktrace).ShouldBeTrue(); + } + + public void AssertSubscriptionActivityHasExpectedTags(Activity activity, string stream, string eventId, string? subscriptionId) { + var expectedTags = new Dictionary { + { TelemetryTags.Database.System, EventStoreClientDiagnostics.InstrumentationName }, + { TelemetryTags.Database.Operation, TracingConstants.Operations.Subscribe }, + { TelemetryTags.EventStore.Stream, stream }, + { TelemetryTags.EventStore.EventId, eventId }, + { TelemetryTags.EventStore.EventType, TestEventType }, + { TelemetryTags.EventStore.SubscriptionId, subscriptionId }, + { TelemetryTags.Database.User, TestCredentials.Root.Username } + }; + + foreach (var tag in expectedTags) + activity.Tags.ShouldContain(tag); + } +} \ No newline at end of file diff --git a/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.Helpers.cs b/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.Helpers.cs index d1b6740d2..a75383055 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.Helpers.cs +++ b/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.Helpers.cs @@ -4,27 +4,40 @@ namespace EventStore.Client.Tests; public partial class EventStoreFixture { - public const string TestEventType = "test-event-type"; + public const string TestEventType = "test-event-type"; public const string AnotherTestEventTypePrefix = "another"; - public const string AnotherTestEventType = $"{AnotherTestEventTypePrefix}-test-event-type"; + public const string AnotherTestEventType = $"{AnotherTestEventTypePrefix}-test-event-type"; public T NewClient(Action configure) where T : EventStoreClientBase, new() => - (T)Activator.CreateInstance(typeof(T), new object?[] { ClientSettings.With(configure) })!; - + (T)Activator.CreateInstance(typeof(T), [ClientSettings.With(configure)])!; + public string GetStreamName([CallerMemberName] string? testMethod = null) => $"{testMethod}-{Guid.NewGuid():N}"; - public IEnumerable CreateTestEvents(int count = 1, string? type = null, int metadataSize = 1) => - Enumerable.Range(0, count).Select(index => CreateTestEvent(index, type ?? TestEventType, metadataSize)); + public ReadOnlyMemory CreateMetadataOfSize(int metadataSize) => + Encoding.UTF8.GetBytes($"\"{new string('$', metadataSize)}\""); + + public ReadOnlyMemory CreateTestJsonMetadata() => "{\"Foo\": \"Bar\"}"u8.ToArray(); - protected static EventData CreateTestEvent(int index) => CreateTestEvent(index, TestEventType, 1); + public IEnumerable CreateTestEvents(int count = 1, string? type = null, ReadOnlyMemory? metadata = null) => + Enumerable.Range(0, count).Select(index => CreateTestEvent(index, type ?? TestEventType, metadata)); - protected static EventData CreateTestEvent(int index, string type, int metadataSize) => + public IEnumerable CreateTestEventsThatThrowsException() { + // Ensure initial IEnumerator.Current does not throw + yield return CreateTestEvent(1); + + // Throw after enumerator advances + throw new Exception(); + } + + protected static EventData CreateTestEvent(int index) => CreateTestEvent(index, TestEventType); + + protected static EventData CreateTestEvent(int index, string type, ReadOnlyMemory? metadata = null) => new( Uuid.NewUuid(), type, Encoding.UTF8.GetBytes($$"""{"x":{{index}}}"""), - Encoding.UTF8.GetBytes($"\"{new string('$', metadataSize)}\"") + metadata ); public async Task CreateTestUser(bool withoutGroups = true, bool useUserCredentials = false) { @@ -32,24 +45,29 @@ public async Task CreateTestUser(bool withoutGroups = true, bool useUs return result.First(); } - public Task CreateTestUsers(int count = 3, bool withoutGroups = true, bool useUserCredentials = false) => + public Task CreateTestUsers( + int count = 3, bool withoutGroups = true, bool useUserCredentials = false + ) => Fakers.Users .RuleFor(x => x.Groups, f => withoutGroups ? Array.Empty() : f.Lorem.Words()) .Generate(count) .Select( async user => { await Users.CreateUserAsync( - user.LoginName, user.FullName, user.Groups, user.Password, + user.LoginName, + user.FullName, + user.Groups, + user.Password, userCredentials: useUserCredentials ? user.Credentials : TestCredentials.Root ); return user; } ).WhenAll(); - + public async Task RestartService(TimeSpan delay) { await Service.Restart(delay); await Streams.WarmUp(); Log.Information("Service restarted."); } -} +} \ No newline at end of file diff --git a/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.cs b/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.cs index 1744ceda6..50faebf7a 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.cs +++ b/test/EventStore.Client.Tests.Common/Fixtures/EventStoreFixture.cs @@ -8,7 +8,10 @@ namespace EventStore.Client.Tests; -public record EventStoreFixtureOptions(EventStoreClientSettings ClientSettings, IDictionary Environment) { +public record EventStoreFixtureOptions( + EventStoreClientSettings ClientSettings, + IDictionary Environment +) { public EventStoreFixtureOptions RunInMemory(bool runInMemory = true) => this with { Environment = Environment.With(x => x["EVENTSTORE_MEM_DB"] = runInMemory.ToString()) }; @@ -24,7 +27,7 @@ this with { public EventStoreFixtureOptions WithoutDefaultCredentials() => this with { ClientSettings = ClientSettings.With(x => x.DefaultCredentials = null) }; - + public EventStoreFixtureOptions WithMaxAppendSize(uint maxAppendSize) => this with { Environment = Environment.With(x => x["EVENTSTORE_MAX_APPEND_SIZE"] = $"{maxAppendSize}") }; } @@ -54,8 +57,7 @@ protected EventStoreFixture(ConfigureFixture configure) { if (GlobalEnvironment.UseCluster) { Options = configure(EventStoreTestCluster.DefaultOptions()); Service = new EventStoreTestCluster(Options); - } - else { + } else { Options = configure(EventStoreTestNode.DefaultOptions()); Service = new EventStoreTestNode(Options); } @@ -64,7 +66,7 @@ protected EventStoreFixture(ConfigureFixture configure) { List TestRuns { get; } = new(); public ILogger Log => Logger; - + public ITestService Service { get; } public EventStoreFixtureOptions Options { get; } public Faker Faker { get; } = new Faker(); @@ -80,7 +82,7 @@ protected EventStoreFixture(ConfigureFixture configure) { public Func OnSetup { get; init; } = () => Task.CompletedTask; public Func OnTearDown { get; init; } = () => Task.CompletedTask; - + /// /// must test this /// @@ -96,62 +98,62 @@ protected EventStoreFixture(ConfigureFixture configure) { DefaultCredentials = Options.ClientSettings.DefaultCredentials, DefaultDeadline = Options.ClientSettings.DefaultDeadline }; - + InterlockedBoolean WarmUpCompleted { get; } = new InterlockedBoolean(); SemaphoreSlim WarmUpGatekeeper { get; } = new(1, 1); - public void CaptureTestRun(ITestOutputHelper outputHelper) { var testRunId = Logging.CaptureLogs(outputHelper); TestRuns.Add(testRunId); Logger.Information(">>> Test Run {TestRunId} {Operation} <<<", testRunId, "starting"); Service.ReportStatus(); } - + public async Task InitializeAsync() { await Service.Start(); EventStoreVersion = GetEventStoreVersion(); EventStoreHasLastStreamPosition = (EventStoreVersion?.Major ?? int.MaxValue) >= 21; - + await WarmUpGatekeeper.WaitAsync(); - + try { if (!WarmUpCompleted.CurrentValue) { Logger.Warning("*** Warmup started ***"); await Task.WhenAll( 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 => Streams = await x.WarmUp()), + InitClient( + async x => Projections = await x.WarmUp(), + Options.Environment["EVENTSTORE_RUN_PROJECTIONS"] != "None" + ), InitClient(async x => Subscriptions = await x.WarmUp()), - InitClient(async x => Operations = await x.WarmUp()) + InitClient(async x => Operations = await x.WarmUp()) ); - + WarmUpCompleted.EnsureCalledOnce(); - + Logger.Warning("*** Warmup completed ***"); - } - else { + } else { Logger.Information("*** Warmup skipped ***"); } - } - finally { + } finally { WarmUpGatekeeper.Release(); } - + await OnSetup(); - + return; async Task InitClient(Func action, bool execute = true) where T : EventStoreClientBase { if (!execute) return default(T)!; + var client = (Activator.CreateInstance(typeof(T), new object?[] { ClientSettings }) as T)!; await action(client); return client; } - - + static Version GetEventStoreVersion() { const string versionPrefix = "EventStoreDB version"; @@ -166,7 +168,10 @@ static Version GetEventStoreVersion() { using var log = eventstore.Logs(true, cancellator.Token); foreach (var line in log.ReadToEnd()) { if (line.StartsWith(versionPrefix) && - Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { + Version.TryParse( + new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), + out var version + )) { return version; } } @@ -184,8 +189,7 @@ IEnumerable ReadVersion(string s) { public async Task DisposeAsync() { try { await OnTearDown(); - } - catch { + } catch { // ignored } @@ -206,11 +210,13 @@ public class EventStoreSharedDatabaseFixture : ICollectionFixture : IClassFixture where TFixture : EventStoreFixture { - protected EventStoreTests(ITestOutputHelper output, TFixture fixture) => Fixture = fixture.With(x => x.CaptureTestRun(output)); - + protected EventStoreTests(ITestOutputHelper output, TFixture fixture) => + Fixture = fixture.With(x => x.CaptureTestRun(output)); + protected TFixture Fixture { get; } } [Collection(nameof(EventStoreSharedDatabaseFixture))] -public abstract class EventStoreSharedDatabaseTests(ITestOutputHelper output, TFixture fixture) : EventStoreTests(output, fixture) +public abstract class EventStoreSharedDatabaseTests(ITestOutputHelper output, TFixture fixture) + : EventStoreTests(output, fixture) where TFixture : EventStoreFixture; diff --git a/test/EventStore.Client.Tests.Common/docker-compose.certs.yml b/test/EventStore.Client.Tests.Common/docker-compose.certs.yml index eeb058537..49c16183c 100644 --- a/test/EventStore.Client.Tests.Common/docker-compose.certs.yml +++ b/test/EventStore.Client.Tests.Common/docker-compose.certs.yml @@ -16,7 +16,7 @@ services: network_mode: none cert-gen: - image: ghcr.io/eventstore/es-gencert-cli:1.3 + image: ghcr.io/eventstore/es-gencert-cli:1.3.0 container_name: cert-gen user: "1000:1000" entrypoint: [ "/bin/sh","-c" ] @@ -32,4 +32,4 @@ services: volumes: - "${ES_CERTS_CLUSTER}:/tmp/certs" depends_on: - - volumes-provisioner \ No newline at end of file + - volumes-provisioner diff --git a/test/EventStore.Client.Tests.Common/docker-compose.cluster.yml b/test/EventStore.Client.Tests.Common/docker-compose.cluster.yml index 9be153ec4..49591b41f 100644 --- a/test/EventStore.Client.Tests.Common/docker-compose.cluster.yml +++ b/test/EventStore.Client.Tests.Common/docker-compose.cluster.yml @@ -11,7 +11,7 @@ services: network_mode: none cert-gen: - image: ghcr.io/eventstore/es-gencert-cli:1.3 + image: ghcr.io/eventstore/es-gencert-cli:1.3.0 container_name: cert-gen user: "1000:1000" entrypoint: [ "/bin/sh","-c" ] diff --git a/test/EventStore.Client.Tests.Common/docker-compose.yml b/test/EventStore.Client.Tests.Common/docker-compose.yml index 089a99089..86e4536b1 100644 --- a/test/EventStore.Client.Tests.Common/docker-compose.yml +++ b/test/EventStore.Client.Tests.Common/docker-compose.yml @@ -11,7 +11,7 @@ services: network_mode: none cert-gen: - image: ghcr.io/eventstore/es-gencert-cli:1.3 + image: ghcr.io/eventstore/es-gencert-cli:1.3.0 container_name: cert-gen user: "1000:1000" entrypoint: [ "/bin/sh","-c" ]