diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index 70b08886a..e69de29bb 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -1,102 +0,0 @@ -name: Build - -on: - workflow_call: - inputs: - kurrentdb-tag: - description: The docker tag to use. If kurrentdb-image is empty, the action will use the values in the KURRENTDB_DOCKER_IMAGES variable (ci, lts, previous-lts). - required: true - type: string - kurrentdb-image: - description: The docker image to use. Leave this empty to use the image in the KURRENTDB_DOCKER_IMAGES variable. - required: false - type: string - kurrentdb-registry: - description: The docker registry to use. Leave this empty to use the registry in the KURRENTDB_DOCKER_IMAGES variable. - required: false - type: string - test: - description: Which test to run. - required: true - type: string -env: - KURRENTDB_TAG: ${{ inputs.kurrentdb-image != '' && inputs.kurrentdb-tag || fromJSON(vars.KURRENTDB_DOCKER_IMAGES)[inputs.kurrentdb-tag].tag }} - KURRENTDB_IMAGE: ${{ inputs.kurrentdb-image || fromJSON(vars.KURRENTDB_DOCKER_IMAGES)[inputs.kurrentdb-tag].image }} - KURRENTDB_REGISTRY: ${{ inputs.kurrentdb-registry || fromJSON(vars.KURRENTDB_DOCKER_IMAGES)[inputs.kurrentdb-tag].registry }} -jobs: - test: - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - framework: [ net8.0, net9.0 ] - os: [ ubuntu-latest ] - configuration: [ release ] - runs-on: ${{ matrix.os }} - name: ${{ inputs.test }} (${{ matrix.os }}, ${{ matrix.framework }}) - steps: - - name: Echo docker details - shell: bash - run: | - echo "${{env.KURRENTDB_REGISTRY}}" - echo "${{env.KURRENTDB_IMAGE}}" - echo "${{env.KURRENTDB_TAG}}" - - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Login to Cloudsmith via docker.cloudsmith.io - uses: docker/login-action@v3 - with: - registry: docker.cloudsmith.io - username: ${{ secrets.CLOUDSMITH_CICD_USER }} - password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} - - - name: Login to Cloudsmith via docker.kurrent.io - uses: docker/login-action@v3 - with: - registry: docker.kurrent.io - username: ${{ secrets.CLOUDSMITH_CICD_USER }} - password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} - - - name: Login to Cloudsmith via docker.eventstore.com - uses: docker/login-action@v3 - with: - registry: docker.eventstore.com - username: ${{ secrets.CLOUDSMITH_CICD_USER }} - password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} - - - name: Pull KurrentDB Image - shell: bash - run: | - docker pull ${{ env.KURRENTDB_REGISTRY }}/${{ env.KURRENTDB_IMAGE}}:${{ env.KURRENTDB_TAG}} - - - name: Install dotnet SDKs - uses: actions/setup-dotnet@v3 - with: - dotnet-version: | - 8.0.x - 9.0.x - - - name: Generate certificates - shell: bash - run: sudo ./gencert.sh - - - name: Restore dependencies - shell: bash - run: dotnet restore - - - name: Run Tests - shell: bash - env: - ES_DOCKER_TAG: ${{env.KURRENTDB_TAG}} - ES_DOCKER_REGISTRY: ${{env.KURRENTDB_REGISTRY}}/${{env.KURRENTDB_IMAGE}} - KURRENTDB_LICENSE_KEY: ${{ secrets.KURRENTDB_TEST_LICENSE_KEY }} - run: | - dotnet test --configuration ${{ matrix.configuration }} --blame \ - --logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \ - --framework ${{ matrix.framework }} \ - --filter "Category=Target:${{ inputs.test }}" \ - test/KurrentDB.Client.Tests \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 80e1c7ead..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - master - tags: - - v* - workflow_dispatch: - -jobs: - ce: - uses: ./.github/workflows/base.yml - strategy: - fail-fast: false - matrix: - kurrentdb-tag: [ ci, lts ] - test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement, Plugins, Security, Misc ] - name: Test (${{ matrix.kurrentdb-tag }}) - with: - kurrentdb-tag: ${{ matrix.kurrentdb-tag }} - test: ${{ matrix.test }} - secrets: inherit - - ee: - uses: ./.github/workflows/base.yml - strategy: - fail-fast: false - matrix: - kurrentdb-tag: [ previous-lts ] - test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement, Security, Misc ] - name: Test (${{ matrix.kurrentdb-tag }}) - with: - kurrentdb-tag: ${{ matrix.kurrentdb-tag }} - test: ${{ matrix.test }} - secrets: inherit diff --git a/.github/workflows/dispatch-ce.yml b/.github/workflows/dispatch-ce.yml deleted file mode 100644 index dc76992af..000000000 --- a/.github/workflows/dispatch-ce.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Dispatch CE - -on: - workflow_dispatch: - inputs: - docker-tag: - description: "Docker tag" - required: true - type: string - docker-image: - description: "Docker image" - required: true - type: string - -jobs: - test: - uses: ./.github/workflows/base.yml - strategy: - fail-fast: false - matrix: - test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement, Security, Misc ] - name: Test CE (${{ inputs.docker-tag }}) - with: - docker-tag: ${{ inputs.docker-tag }} - docker-image: ${{ inputs.docker-image }} - test: ${{ matrix.test }} - secrets: - CLOUDSMITH_CICD_USER: ${{ secrets.CLOUDSMITH_CICD_USER }} - CLOUDSMITH_CICD_TOKEN: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} diff --git a/.github/workflows/dispatch-ee.yml b/.github/workflows/dispatch-ee.yml index 8d7260aec..e69de29bb 100644 --- a/.github/workflows/dispatch-ee.yml +++ b/.github/workflows/dispatch-ee.yml @@ -1,36 +0,0 @@ -name: Dispatch -run-name: "Dispatch [${{ inputs.correlation-id }}]" - -on: - workflow_dispatch: - inputs: - kurrentdb-tag: - description: "The KurrentDB docker tag to use. If kurrentdb-image is empty, the action will use the values in the KURRENTDB_DOCKER_IMAGES variable (ci, lts, previous-lts)." - required: true - type: string - kurrentdb-image: - description: "The KurrentDB docker image to test against. Leave this empty to use the image in the KURRENTDB_DOCKER_IMAGES variable" - required: false - type: string - kurrentdb-registry: - description: "The docker registry containing the KurrentDB docker image. Leave this empty to use the registry in the KURRENTDB_DOCKER_IMAGES variable." - required: false - type: string - correlation-id: - type: string - description: Optional CorrelationId to identify the workflow run - required: false -jobs: - test: - uses: ./.github/workflows/base.yml - strategy: - fail-fast: false - matrix: - test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement, Plugins ] - name: Test (${{ inputs.kurrentdb-tag }}) - with: - kurrentdb-tag: ${{ inputs.kurrentdb-tag }} - kurrentdb-image: ${{ inputs.kurrentdb-image }} - kurrentdb-registry: ${{ inputs.kurrentdb-registry }} - test: ${{ matrix.test }} - secrets: inherit diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 000000000..356cfce04 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,19 @@ +name: Client + +on: + pull_request: + push: + branches: + - master + +jobs: + old: + name: ${{ matrix.runtime }} + uses: ./.github/workflows/test.yml + strategy: + fail-fast: false + matrix: + runtime: [ previous-lts, lts, ci ] + with: + runtime: ${{ matrix.runtime }} + secrets: inherit diff --git a/.github/workflows/load-configuration.yml b/.github/workflows/load-configuration.yml new file mode 100644 index 000000000..67ef6464c --- /dev/null +++ b/.github/workflows/load-configuration.yml @@ -0,0 +1,66 @@ +name: Load KurrentDB Runtime Configuration +on: + workflow_call: + inputs: + runtime: + description: "The runtime's name. Current options are: `ci`, `previous-lts`, `latest` and `qa`" + required: true + type: string + + registry: + description: "When in QA mode, the Docker registry to use" + type: string + required: false + + image: + description: "When in QA mode, the Docker image to use" + type: string + required: false + + tag: + description: "When in QA mode, the Docker image tag to use" + type: string + required: false + + outputs: + runtime: + description: The runtime's name + value: ${{ inputs.runtime }} + + registry: + description: The Docker registry + value: ${{ jobs.load.outputs.registry }} + + image: + description: The Docker image + value: ${{ jobs.load.outputs.image }} + + tag: + description: The Docker image tag + value: ${{ jobs.load.outputs.tag }} + +jobs: + load: + runs-on: ubuntu-latest + outputs: + registry: ${{ steps.set.outputs.registry }} + image: ${{ steps.set.outputs.image }} + tag: ${{ steps.set.outputs.tag }} + + steps: + - name: Set KurrentDB Runtime Configuration Properties + id: set + run: | + case ${{ inputs.runtime }} in + "qa") + echo "registry=${{ inputs.registry }}" >> $GITHUB_OUTPUT + echo "image=${{ inputs.image }}" >> $GITHUB_OUTPUT + echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT + ;; + + *) + echo "registry=${{ fromJSON(vars.KURRENTDB_DOCKER_IMAGES)[inputs.runtime].registry }}" >> $GITHUB_OUTPUT + echo "image=${{ fromJSON(vars.KURRENTDB_DOCKER_IMAGES)[inputs.runtime].image }}" >> $GITHUB_OUTPUT + echo "tag=${{ fromJSON(vars.KURRENTDB_DOCKER_IMAGES)[inputs.runtime].tag }}" >> $GITHUB_OUTPUT + ;; + esac diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..d07368e5d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,83 @@ +name: Test + +on: + workflow_call: + secrets: + CLOUDSMITH_CICD_USER: + required: false + CLOUDSMITH_CICD_TOKEN: + required: false + inputs: + runtime: + required: true + type: string + + registry: + description: "The Docker registry to use" + type: string + required: false + + image: + description: "The Docker image to use" + type: string + required: false + + tag: + description: "The Docker image tag to use" + type: string + required: false + +jobs: + load_configuration: + uses: ./.github/workflows/load-configuration.yml + with: + runtime: ${{ inputs.runtime }} + registry: ${{ inputs.registry }} + image: ${{ inputs.image }} + tag: ${{ inputs.tag }} + + test: + name: Test + needs: load_configuration + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + framework: [ net8.0, net9.0 ] + test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement, Security, Diagnostics, Misc ] + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Login to Cloudsmith + uses: docker/login-action@v3 + with: + registry: docker.kurrent.io + username: ${{ secrets.CLOUDSMITH_CICD_USER }} + password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} + + - name: Install dotnet SDKs + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Run Tests + shell: bash + env: + TESTCONTAINER_KURRENTDB_IMAGE: "${{ needs.load_configuration.outputs.registry }}/${{ needs.load_configuration.outputs.image }}:${{ needs.load_configuration.outputs.tag }}" + run: | + sudo ./gencert.sh + dotnet test --configuration release --blame \ + --logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \ + --framework ${{ matrix.framework }} \ + --filter "Category=Target:${{ matrix.test }}" \ + test/KurrentDB.Client.Tests + diff --git a/.gitignore b/.gitignore index bfe8c164d..1f0b1b9da 100644 --- a/.gitignore +++ b/.gitignore @@ -366,7 +366,6 @@ certs-cluster/ .DS_Store docs/public - -.cache .temp -node_modules \ No newline at end of file +.cache +node_modules diff --git a/docs/api/appending-events.md b/docs/api/appending-events.md index 35f220249..26ac82986 100644 --- a/docs/api/appending-events.md +++ b/docs/api/appending-events.md @@ -136,4 +136,60 @@ await client.AppendToStreamAsync( new[] { eventData }, userCredentials: new UserCredentials("admin", "changeit") ); -``` \ No newline at end of file +``` + +## Append to multiple streams + +::: note +This feature is only available in KurrentDB 25.1 and later. +::: + +You can append events to multiple streams in a single atomic operation. Either all streams are updated, or the entire operation fails. + +```cs +using System.Text.Json; + +AppendStreamRequest[] requests = [ + new( + "order-stream", + StreamState.Any, + [ + new EventData(Uuid.NewUuid(), "OrderCreated", Encoding.UTF8.GetBytes("{\"orderId\": \"21345\", \"amount\": 99.99}")) + ] + ), + new( + "inventory-stream", + StreamState.Any, + [ + new EventData(Uuid.NewUuid(), "ItemReserved", Encoding.UTF8.GetBytes("{\"itemId\": \"abc123\", \"quantity\": 2}")) + ] + ) +]; + +await client.MultiStreamAppendAsync(requests); +``` + +The result returns the position of the last appended record in the transaction and a collection of responses for each stream appended in the transaction. + +::: warning +The metadata for an event must be a valid JSON object where both keys and values are strings. It is essential that the JSON is well-formed and not missing, as any malformed or absent metadata will result in an `ArgumentException` being thrown. + +You can use the provided `Encode` and `Decode` extension methods when writing +and reading metadata. For example: + +```cs +var metadata = new Dictionary +{ + { "userId", "user-456" } +}; + +// encode to bytes before appending +var metadataBytes = metadata.Encode(); +``` + +And when reading metadata back: + +```cs +var metadata = metadataBytes.Decode(); +``` +::: diff --git a/samples/appending-events/Program.cs b/samples/appending-events/Program.cs index 5b344434b..c248d1a8d 100644 --- a/samples/appending-events/Program.cs +++ b/samples/appending-events/Program.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS8321 // Local function is declared but never used +using System.Text.Json; + +#pragma warning disable CS8321 // Local function is declared but never used var settings = KurrentDBClientSettings.Create("kurrentdb://localhost:2113?tls=false"); @@ -6,6 +8,7 @@ await using var client = new KurrentDBClient(settings); +await MultiStreamAppend(client); await AppendToStream(client); await AppendWithConcurrencyCheck(client); await AppendWithNoStream(client); @@ -13,6 +16,29 @@ return; +static async Task MultiStreamAppend(KurrentDBClient client) { + var metadata = JsonSerializer.SerializeToUtf8Bytes( + new { + TimeStamp = DateTime.UtcNow, + } + ); + + AppendStreamRequest[] requests = [ + new( + "stream-one", + StreamState.NoStream, + [new EventData(Uuid.NewUuid(), "event-one", "hello"u8.ToArray(), metadata)] + ), + new( + "stream-two", + StreamState.NoStream, + [new EventData(Uuid.NewUuid(), "event-one", "hello"u8.ToArray(), metadata)] + ) + ]; + + await client.MultiStreamAppendAsync(requests); +} + static async Task AppendToStream(KurrentDBClient client) { #region append-to-stream @@ -167,3 +193,5 @@ await client.AppendToStreamAsync( #endregion overriding-user-credentials } + +record Metadata(DateTime TimeStamp); diff --git a/src/KurrentDB.Client/Core/Common/Constants.cs b/src/KurrentDB.Client/Core/Common/Constants.cs index b58bdee63..8637c9af4 100644 --- a/src/KurrentDB.Client/Core/Common/Constants.cs +++ b/src/KurrentDB.Client/Core/Common/Constants.cs @@ -39,11 +39,15 @@ 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]; + const string SystemPrefix = "$"; + + public const string Type = "type"; + public const string Created = "created"; + public const string ContentType = "content-type"; + public const string SchemaName = $"{SystemPrefix}schema.name"; + public const string SchemaFormat = $"{SystemPrefix}schema.format"; + + public static readonly string[] RequiredMetadata = [Type, ContentType]; public static class ContentTypes { public const string ApplicationJson = "application/json"; diff --git a/src/KurrentDB.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs b/src/KurrentDB.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs index 3d950a9b0..d85d32613 100644 --- a/src/KurrentDB.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs +++ b/src/KurrentDB.Client/Core/Common/Diagnostics/ActivitySourceExtensions.cs @@ -1,9 +1,10 @@ +// ReSharper disable ConvertIfStatementToSwitchStatement // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract using System.Diagnostics; using KurrentDB.Diagnostics; using KurrentDB.Diagnostics.Telemetry; -using KurrentDB.Diagnostics.Tracing; +using static KurrentDB.Diagnostics.Tracing.TracingConstants; namespace KurrentDB.Client.Diagnostics; @@ -54,7 +55,7 @@ public static void TraceSubscriptionEvent( userCredentials?.Username ?? settings.DefaultCredentials?.Username ); - StartActivity(source, TracingConstants.Operations.Subscribe, ActivityKind.Consumer, tags, parentContext) + StartActivity(source, Operations.Subscribe, ActivityKind.Consumer, tags, parentContext) ?.Dispose(); } @@ -67,7 +68,7 @@ public static void TraceSubscriptionEvent( return null; (tags ??= new ActivityTagsCollection()) - .WithRequiredTag(TelemetryTags.Database.System, "kurrent") + .WithRequiredTag(TelemetryTags.Database.System, KurrentDBClientDiagnostics.InstrumentationName) .WithRequiredTag(TelemetryTags.Database.Operation, operationName); return source diff --git a/src/KurrentDB.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs b/src/KurrentDB.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs index 89230f98f..f1e0b5edb 100644 --- a/src/KurrentDB.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs +++ b/src/KurrentDB.Client/Core/Common/Diagnostics/EventMetadataExtensions.cs @@ -7,6 +7,13 @@ namespace KurrentDB.Client.Diagnostics; static class EventMetadataExtensions { + public static void InjectTracingContext(this Dictionary metadata, Activity? activity) { + if (!KurrentDBClientDiagnostics.ActivitySource.HasListeners() || activity is null) return; + + metadata[TracingConstants.Metadata.TraceId] = activity.TraceId.ToString(); + metadata[TracingConstants.Metadata.SpanId] = activity.SpanId.ToString(); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ReadOnlySpan InjectTracingContext( this ReadOnlyMemory eventMetadata, Activity? activity diff --git a/src/KurrentDB.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs b/src/KurrentDB.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs index 8b0de279d..5562748ad 100644 --- a/src/KurrentDB.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs +++ b/src/KurrentDB.Client/Core/Common/Diagnostics/Tracing/TracingConstants.cs @@ -4,7 +4,8 @@ namespace KurrentDB.Diagnostics.Tracing; static partial class TracingConstants { public static class Operations { - public const string Append = "streams.append"; - public const string Subscribe = "streams.subscribe"; + public const string Append = "streams.append"; + public const string MultiAppend = "streams.multi-append"; + public const string Subscribe = "streams.subscribe"; } } diff --git a/src/KurrentDB.Client/Core/Common/MetadataExtensions.cs b/src/KurrentDB.Client/Core/Common/MetadataExtensions.cs index c2136f138..72edd0bd0 100644 --- a/src/KurrentDB.Client/Core/Common/MetadataExtensions.cs +++ b/src/KurrentDB.Client/Core/Common/MetadataExtensions.cs @@ -1,8 +1,60 @@ +using System.Text.Json; using Grpc.Core; namespace KurrentDB.Client; static class MetadataExtensions { + /// + /// Encodes a dictionary of string key-value pairs as JSON metadata bytes. + /// + /// The dictionary to encode. + /// UTF-8 encoded JSON bytes representing the metadata. + public static ReadOnlyMemory Encode(this Dictionary metadata) => + JsonSerializer.SerializeToUtf8Bytes(metadata); + + /// + /// Encodes an anonymous object as JSON metadata bytes. + /// + /// The object to encode. + /// UTF-8 encoded JSON bytes representing the metadata. + public static ReadOnlyMemory Encode(this object metadata) => + JsonSerializer.SerializeToUtf8Bytes(metadata); + + /// + /// Decodes JSON metadata bytes as a dictionary of string key-value pairs. + /// + /// The metadata bytes to decode. + /// A dictionary of string key-value pairs, or null if the metadata is empty. + public static Dictionary? Decode(this ReadOnlyMemory metadata) { + if (metadata.IsEmpty) + return null; + + return JsonSerializer.Deserialize>(metadata.Span); + } + + /// + /// Decodes the metadata from an event record as a dictionary of string key-value pairs. + /// + /// The event record containing metadata to decode. + /// A dictionary of string key-value pairs, or null if the metadata is empty. + public static Dictionary? Decode(this EventRecord eventRecord) => + eventRecord.Metadata.Decode(); + + /// + /// Decodes the metadata from a resolved event as a dictionary of string key-value pairs. + /// + /// The resolved event containing metadata to decode. + /// A dictionary of string key-value pairs, or null if the metadata is empty. + public static Dictionary? Decode(this ResolvedEvent resolvedEvent) => + resolvedEvent.OriginalEvent.Metadata.Decode(); + + /// + /// Attempts to retrieve a value from the gRPC metadata by key. + /// + /// The gRPC metadata collection. + /// The key to search for. + /// When this method returns, contains the value associated with the key, if found; otherwise, null. + /// true if the key was found; otherwise, false. public static bool TryGetValue(this Metadata metadata, string key, out string? value) { value = default; @@ -17,11 +69,23 @@ public static bool TryGetValue(this Metadata metadata, string key, out string? v return false; } + /// + /// Retrieves a stream state value from the gRPC metadata by key. + /// + /// The gRPC metadata collection. + /// The key to search for. + /// The parsed stream state value if found and valid; otherwise, . public static StreamState GetStreamState(this Metadata metadata, string key) => metadata.TryGetValue(key, out var s) && ulong.TryParse(s, out var value) ? value : StreamState.NoStream; + /// + /// Retrieves an integer value from the gRPC metadata by key. + /// + /// The gRPC metadata collection. + /// The key to search for. + /// The parsed integer value if found and valid; otherwise, 0. public static int GetIntValueOrDefault(this Metadata metadata, string key) => metadata.TryGetValue(key, out var s) && int.TryParse(s, out var value) ? value diff --git a/src/KurrentDB.Client/Core/Exceptions/AccessDeniedException.cs b/src/KurrentDB.Client/Core/Exceptions/AccessDeniedException.cs index 13ea71b2a..c2a34eb36 100644 --- a/src/KurrentDB.Client/Core/Exceptions/AccessDeniedException.cs +++ b/src/KurrentDB.Client/Core/Exceptions/AccessDeniedException.cs @@ -1,4 +1,4 @@ -using System; +using Grpc.Core; namespace KurrentDB.Client { /// @@ -18,5 +18,11 @@ public AccessDeniedException(string message, Exception innerException) : base(me public AccessDeniedException() : base("Access denied.") { } + + public static AccessDeniedException FromRpcException(RpcException ex) => FromRpcStatus(ex.GetRpcStatus()!); + + public static AccessDeniedException FromRpcStatus(Google.Rpc.Status ex) { + return new(ex.Message, ex.ToRpcException()); + } } } diff --git a/src/KurrentDB.Client/Core/Exceptions/AppendTransactionMaxSizeExceededException.cs b/src/KurrentDB.Client/Core/Exceptions/AppendTransactionMaxSizeExceededException.cs new file mode 100644 index 000000000..1481b23c5 --- /dev/null +++ b/src/KurrentDB.Client/Core/Exceptions/AppendTransactionMaxSizeExceededException.cs @@ -0,0 +1,33 @@ +using Grpc.Core; +using KurrentDB.Protocol.V2.Streams.Errors; + +namespace KurrentDB.Client; + +/// +/// Exception thrown when a transaction exceeds the allowed maximum size limit. +/// +public class AppendTransactionMaxSizeExceededException(int size, int maxSize, Exception? innerException = null) + : Exception( + $"The total size of the append transaction ({size}) exceeds the maximum allowed size of {maxSize} bytes by {size - maxSize}", + innerException + ) { + /// + /// The size of the huge transaction in bytes. + /// + public int Size { get; } = size; + + /// + /// The maximum allowed size of the append transaction in bytes. + /// + public int MaxSize { get; } = maxSize; + + public static AppendTransactionMaxSizeExceededException FromRpcException(RpcException ex) => FromRpcStatus(ex.GetRpcStatus()!); + + public static AppendTransactionMaxSizeExceededException FromRpcStatus(Google.Rpc.Status ex) { + var details = ex.GetDetail(); + return new AppendTransactionMaxSizeExceededException( + details.Size, + details.MaxSize + ); + } +} diff --git a/src/KurrentDB.Client/Core/Exceptions/NotLeaderException.cs b/src/KurrentDB.Client/Core/Exceptions/NotLeaderException.cs index f2e0e82b0..bcd853f6b 100644 --- a/src/KurrentDB.Client/Core/Exceptions/NotLeaderException.cs +++ b/src/KurrentDB.Client/Core/Exceptions/NotLeaderException.cs @@ -1,5 +1,6 @@ -using System; using System.Net; +using Grpc.Core; +using Kurrent.Rpc; namespace KurrentDB.Client { /// @@ -22,5 +23,15 @@ public NotLeaderException(string host, int port, Exception? exception = null) : $"Not leader. New leader at {host}:{port}.", exception) { LeaderEndpoint = new DnsEndPoint(host, port); } + + public static NotLeaderException FromRpcException(RpcException ex) => FromRpcStatus(ex.GetRpcStatus()!); + + public static NotLeaderException FromRpcStatus(Google.Rpc.Status ex) { + var details = ex.GetDetail(); + return new NotLeaderException( + details.CurrentLeader.Host, + details.CurrentLeader.Port + ); + } } } diff --git a/src/KurrentDB.Client/Core/Exceptions/StreamTombstonedException.cs b/src/KurrentDB.Client/Core/Exceptions/StreamTombstonedException.cs new file mode 100644 index 000000000..6c9ad341a --- /dev/null +++ b/src/KurrentDB.Client/Core/Exceptions/StreamTombstonedException.cs @@ -0,0 +1,28 @@ +using Grpc.Core; +using KurrentDB.Protocol.V2.Streams.Errors; + +namespace KurrentDB.Client; + +public class StreamTombstonedException : Exception { + /// + /// The name of the tombstoned stream. + /// + public readonly string Stream; + + /// + /// Constructs a new instance of . + /// + /// The name of the tombstoned stream. + /// + public StreamTombstonedException(string stream, Exception? exception = null) + : base($"Event stream '{stream}' is tombstoned.", exception) { + Stream = stream; + } + + public static StreamTombstonedException FromRpcException(RpcException ex) => FromRpcStatus(ex.GetRpcStatus()!); + + public static StreamTombstonedException FromRpcStatus(Google.Rpc.Status ex) { + var details = ex.GetDetail(); + return new StreamTombstonedException(details.Stream); + } +} diff --git a/src/KurrentDB.Client/Core/Exceptions/WrongExpectedVersionException.cs b/src/KurrentDB.Client/Core/Exceptions/WrongExpectedVersionException.cs index 0736dc84c..db6c7c391 100644 --- a/src/KurrentDB.Client/Core/Exceptions/WrongExpectedVersionException.cs +++ b/src/KurrentDB.Client/Core/Exceptions/WrongExpectedVersionException.cs @@ -1,4 +1,6 @@ using System; +using Grpc.Core; +using KurrentDB.Protocol.V2.Streams.Errors; namespace KurrentDB.Client { /// @@ -45,5 +47,16 @@ public WrongExpectedVersionException(string streamName, StreamState expectedStre ExpectedVersion = expectedStreamState.ToInt64(); ActualVersion = actualStreamState.ToInt64(); } + + public static WrongExpectedVersionException FromRpcException(RpcException ex) => FromRpcStatus(ex.GetRpcStatus()!); + + public static WrongExpectedVersionException FromRpcStatus(Google.Rpc.Status ex) { + var details = ex.GetDetail(); + return new WrongExpectedVersionException( + details.Stream, + StreamState.StreamRevision((ulong)details.ExpectedRevision), + StreamState.StreamRevision((ulong)details.ActualRevision) + ); + } } } diff --git a/src/KurrentDB.Client/Core/GrpcServerCapabilitiesClient.cs b/src/KurrentDB.Client/Core/GrpcServerCapabilitiesClient.cs index e93ee2be7..17c156788 100644 --- a/src/KurrentDB.Client/Core/GrpcServerCapabilitiesClient.cs +++ b/src/KurrentDB.Client/Core/GrpcServerCapabilitiesClient.cs @@ -1,5 +1,5 @@ -using EventStore.Client.ServerFeatures; using Grpc.Core; +using static KurrentDB.Protocol.Users.V1.ServerFeatures; namespace KurrentDB.Client { internal class GrpcServerCapabilitiesClient : IServerCapabilitiesClient { @@ -13,7 +13,7 @@ public async Task GetAsync( CallInvoker callInvoker, CancellationToken cancellationToken) { - var client = new ServerFeatures.ServerFeaturesClient(callInvoker); + var client = new ServerFeaturesClient(callInvoker); using var call = client.GetSupportedMethodsAsync( new(), KurrentDBCallOptions.CreateNonStreaming( @@ -29,6 +29,7 @@ public async Task GetAsync( var supportsPersistentSubscriptionsRestartSubsystem = false; var supportsPersistentSubscriptionsReplayParked = false; var supportsPersistentSubscriptionsList = false; + var supportsMultiStreamAppend = false; var response = await call.ResponseAsync.ConfigureAwait(false); @@ -52,6 +53,9 @@ public async Task GetAsync( case ("event_store.client.persistent_subscriptions.persistentsubscriptions", "list"): supportsPersistentSubscriptionsList = true; continue; + case ("kurrentdb.protocol.v2.streams.streamsservice", "appendsession"): + supportsMultiStreamAppend = true; + continue; } } @@ -61,7 +65,8 @@ public async Task GetAsync( SupportsPersistentSubscriptionsGetInfo: supportsPersistentSubscriptionsGetInfo, SupportsPersistentSubscriptionsRestartSubsystem: supportsPersistentSubscriptionsRestartSubsystem, SupportsPersistentSubscriptionsReplayParked: supportsPersistentSubscriptionsReplayParked, - SupportsPersistentSubscriptionsList: supportsPersistentSubscriptionsList); + SupportsPersistentSubscriptionsList: supportsPersistentSubscriptionsList, + SupportsMultiStreamAppend: supportsMultiStreamAppend); } catch (Exception ex) when (ex.GetBaseException() is RpcException rpcException && rpcException.StatusCode == StatusCode.Unimplemented) { diff --git a/src/KurrentDB.Client/Core/Interceptors/RequiresLeaderInterceptor.cs b/src/KurrentDB.Client/Core/Interceptors/RequiresLeaderInterceptor.cs new file mode 100644 index 000000000..1073cfd5a --- /dev/null +++ b/src/KurrentDB.Client/Core/Interceptors/RequiresLeaderInterceptor.cs @@ -0,0 +1,67 @@ +// ReSharper disable InconsistentNaming + +using Grpc.Core; +using Grpc.Core.Interceptors; +using static System.StringComparer; + +namespace KurrentDB.Client.Interceptors; + +sealed class RequiresLeaderInterceptor(params string[]? excludedOperations) : Interceptor { + readonly HashSet ExcludedOperations = new(excludedOperations ?? [], OrdinalIgnoreCase); + + public override AsyncUnaryCall AsyncUnaryCall( + TRequest request, + ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation + ) where TRequest : class where TResponse : class => + continuation(request, PrepareContext(context)); + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + ClientInterceptorContext context, + AsyncClientStreamingCallContinuation continuation + ) where TRequest : class where TResponse : class => + continuation(PrepareContext(context)); + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + TRequest request, + ClientInterceptorContext context, + AsyncServerStreamingCallContinuation continuation + ) where TRequest : class where TResponse : class => + continuation(request, PrepareContext(context)); + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + ClientInterceptorContext context, + AsyncDuplexStreamingCallContinuation continuation + ) where TRequest : class where TResponse : class => + continuation(PrepareContext(context)); + + ClientInterceptorContext PrepareContext( + ClientInterceptorContext context + ) where TRequest : class where TResponse : class { + var operationName = Path.GetFileName(context.Method.Name); + + return ExcludedOperations.Contains(operationName) + ? RemoveRequiresLeaderHeader(context) + : context; + } + + static ClientInterceptorContext RemoveRequiresLeaderHeader( + ClientInterceptorContext context + ) where TRequest : class where TResponse : class { + if (context.Options.Headers is null) + return context; + + var headers = new Metadata(); + + context.Options.Headers + .Where(header => header.Key is not Constants.Headers.RequiresLeader) + .ToList() + .ForEach(headers.Add); + + return new ClientInterceptorContext( + context.Method, + context.Host, + context.Options.WithHeaders(headers) + ); + } +} diff --git a/src/KurrentDB.Client/Core/Internal/Exceptions/Utf8JsonReaderExtensions.cs b/src/KurrentDB.Client/Core/Internal/Exceptions/Utf8JsonReaderExtensions.cs new file mode 100644 index 000000000..c45e35b3c --- /dev/null +++ b/src/KurrentDB.Client/Core/Internal/Exceptions/Utf8JsonReaderExtensions.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Text.Json; +using System.Xml; + +namespace KurrentDB.Client.Core.Internal.Exceptions; + +static class Utf8JsonReaderExtensions { + public static bool TryGetTimeSpan(this ref Utf8JsonReader reader, out TimeSpan value) { + if (reader.TokenType == JsonTokenType.String) { + var str = reader.GetString(); + if (TimeSpan.TryParse(str, CultureInfo.InvariantCulture, out value)) + return true; + + try { + value = XmlConvert.ToTimeSpan(str!); + return true; + } catch (FormatException) { + // ignore + } + } + + value = TimeSpan.Zero; + return false; + } +} diff --git a/src/KurrentDB.Client/Core/Internal/SystemTypes.cs b/src/KurrentDB.Client/Core/Internal/SystemTypes.cs new file mode 100644 index 000000000..683a135f9 --- /dev/null +++ b/src/KurrentDB.Client/Core/Internal/SystemTypes.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; + +namespace KurrentDB.Client.Core.Internal; + +/// +/// Provides functionality to resolve and retrieve types by their full names, +/// searching the currently loaded assemblies or scanned assemblies. +/// +static class SystemTypes { + public static readonly Type MissingType = Type.Missing.GetType(); + + public static bool IsBytes(this object value) => value.GetType().IsBytes(); + + public static bool IsBytes(this Type type) => + type == typeof(byte[]) || + type == typeof(ReadOnlyMemory) || + type == typeof(Memory); + + /// + /// Checks if the specified type is the placeholder type representing a missing type. + /// + /// + /// The to check. + /// + /// + /// true if the type is the placeholder for a missing type; otherwise, false. + /// + [DebuggerStepThrough] + public static bool IsMissing(this Type source) => source == MissingType; + + /// + /// Determines whether the specified type is an instantiable class. + /// + /// The type to evaluate. + /// true if the type is a non-abstract class; otherwise, false. + public static bool IsInstantiableClass(this Type type) => type is { IsClass: true, IsAbstract: false }; + + /// + /// Determines whether the type's full name matches the specified name. + /// + /// The type to check. + /// The full name to compare against. + /// true if the type's full name matches the specified name; otherwise, false. + public static bool MatchesFullName(this Type type, string name) => type.FullName?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false; + + /// + /// Determines whether the namespace of the specified matches the given namespace prefix. + /// + /// + /// The whose namespace is to be compared. + /// + /// + /// The namespace prefix to compare with the namespace of the specified type. + /// + /// + /// true if the namespace of the specified type matches the namespace prefix; otherwise, false. + /// + public static bool MatchesNamespace(this Type type, string namespacePrefix) => + type.Namespace?.Equals(namespacePrefix, StringComparison.OrdinalIgnoreCase) ?? false; +} diff --git a/src/KurrentDB.Client/Core/KurrentDBClientBase.cs b/src/KurrentDB.Client/Core/KurrentDBClientBase.cs index 6fb8d704a..03b7c2364 100644 --- a/src/KurrentDB.Client/Core/KurrentDBClientBase.cs +++ b/src/KurrentDB.Client/Core/KurrentDBClientBase.cs @@ -63,6 +63,7 @@ CancellationToken cancellationToken var invoker = channel.CreateCallInvoker() .Intercept(new TypedExceptionInterceptor(_exceptionMap)) .Intercept(new ConnectionNameInterceptor(ConnectionName)) + .Intercept(new RequiresLeaderInterceptor("MultiStreamAppendSession")) .Intercept(new ReportLeaderInterceptor(onReconnectionRequired)); if (Settings.Interceptors is not null) { diff --git a/src/KurrentDB.Client/Core/ServerCapabilities.cs b/src/KurrentDB.Client/Core/ServerCapabilities.cs index 076bcc050..f59cf52dd 100644 --- a/src/KurrentDB.Client/Core/ServerCapabilities.cs +++ b/src/KurrentDB.Client/Core/ServerCapabilities.cs @@ -6,5 +6,6 @@ public record ServerCapabilities( bool SupportsPersistentSubscriptionsGetInfo = false, bool SupportsPersistentSubscriptionsRestartSubsystem = false, bool SupportsPersistentSubscriptionsReplayParked = false, + bool SupportsMultiStreamAppend = false, bool SupportsPersistentSubscriptionsList = false); } diff --git a/src/KurrentDB.Client/Core/ValueMapper.cs b/src/KurrentDB.Client/Core/ValueMapper.cs new file mode 100644 index 000000000..f8b6e1caa --- /dev/null +++ b/src/KurrentDB.Client/Core/ValueMapper.cs @@ -0,0 +1,17 @@ +using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; +using JetBrains.Annotations; + +namespace KurrentDB.Client; + +[PublicAPI] +static class ValueMapper { + public static MapField MapToMapValue(this Dictionary source) => + source.Aggregate( + new MapField(), + (seed, entry) => { + seed.Add(entry.Key, Value.ForString(entry.Value)); + return seed; + } + ); +} diff --git a/src/KurrentDB.Client/Core/proto/kurrent/rpc/errors.proto b/src/KurrentDB.Client/Core/proto/kurrent/rpc/errors.proto new file mode 100644 index 000000000..bc2c3cdbc --- /dev/null +++ b/src/KurrentDB.Client/Core/proto/kurrent/rpc/errors.proto @@ -0,0 +1,152 @@ +// ****************************************************************************************** +// This protocol is UNSTABLE in the sense of being subject to change. +// ****************************************************************************************** + +syntax = "proto3"; + +package kurrent.rpc; + +option csharp_namespace = "Kurrent.Rpc"; + +import "kurrent/rpc/rpc.proto"; + +// The canonical server error codes for the Kurrent Platform gRPC APIs. +// These errors represent common failure modes across all Kurrent services. +enum ServerError { + // Default value. This value is not used. + // An error code MUST always be set to a non-zero value. + // If an error code is not explicitly set, it MUST be treated as + // an internal server error (INTERNAL). + UNSPECIFIED = 0; + + // Authentication or authorization failure. + // The client lacks valid credentials or sufficient permissions to perform the requested operation. + // + // Common causes: + // - Missing or invalid authentication tokens + // - Insufficient permissions for the operation + // - Expired credentials + // + // Client action: Check credentials, verify permissions, and re-authenticate if necessary. + // Not retriable without fixing the underlying authorization issue. + SERVER_ERROR_ACCESS_DENIED = 1 [(kurrent.rpc.error) = { + status_code: PERMISSION_DENIED, + has_details: true + }]; + + // The request is malformed or contains invalid data. + // The server cannot process the request due to client error. + // + // Common causes: + // - Invalid field values (e.g., empty required fields, out-of-range numbers) + // - Malformed data formats + // - Validation failures + // + // Client action: Fix the request data and retry. + // Not retriable without modifying the request. + SERVER_ERROR_BAD_REQUEST = 2 [(kurrent.rpc.error) = { + status_code: INVALID_ARGUMENT, + has_details: true + }]; + + // The server is not the cluster leader and cannot process write operations. + // In a clustered deployment, only the leader node can accept write operations. + // + // Common causes: + // - Client connected to a follower node + // - Leader election in progress + // - Network partition + // + // Client action: Redirect the request to the leader node indicated in the error details. + // Retriable after redirecting to the correct leader node. + SERVER_ERROR_NOT_LEADER_NODE = 5 [(kurrent.rpc.error) = { + status_code: FAILED_PRECONDITION, + has_details: true + }]; + + // The operation did not complete within the configured timeout period. + // + // Common causes: + // - Slow disk I/O during writes + // - Cluster consensus delays + // - Network latency + // - Heavy server load + // + // Client action: Retry with exponential backoff. Consider increasing timeout values. + // Retriable - the operation may succeed on retry. + SERVER_ERROR_OPERATION_TIMEOUT = 6 [(kurrent.rpc.error) = { + status_code: DEADLINE_EXCEEDED + }]; + + // The server is starting up or shutting down and cannot process requests. + // + // Common causes: + // - Server is initializing (loading indexes, recovering state) + // - Server is performing graceful shutdown + // - Server is performing maintenance operations + // + // Client action: Retry with exponential backoff. Wait for server to become ready. + // Retriable - the server will become available after initialization completes. + SERVER_ERROR_SERVER_NOT_READY = 7 [(kurrent.rpc.error) = { + status_code: UNAVAILABLE + }]; + + // The server is temporarily overloaded and cannot accept more requests. + // This is a backpressure mechanism to prevent server overload. + // + // Common causes: + // - Too many concurrent requests + // - Resource exhaustion (CPU, memory, disk I/O) + // - Rate limiting triggered + // + // Client action: Retry with exponential backoff. Reduce request rate. + // Retriable - the server may accept requests after load decreases. + SERVER_ERROR_SERVER_OVERLOADED = 8 [(kurrent.rpc.error) = { + status_code: UNAVAILABLE + }]; + + // An internal server error occurred. + // This indicates a bug or unexpected condition in the server. + // + // Common causes: + // - Unhandled exceptions + // - Assertion failures + // - Corrupted internal state + // - Programming errors + // + // Client action: Report to server administrators with request details. + // May be retriable, but likely indicates a server-side issue requiring investigation. + SERVER_ERROR_SERVER_MALFUNCTION = 9 [(kurrent.rpc.error) = { + status_code: INTERNAL + }]; +} + +// Details for ACCESS_DENIED errors. +message AccessDeniedErrorDetails { + // The friendly name of the operation that was denied. + string operation = 1; + + // The username of the user who was denied access. + optional string username = 2; + + // The permission that was required for this operation. + optional string permission = 3; +} + +// Details for NOT_LEADER_NODE errors. +message NotLeaderNodeErrorDetails { + // Information about the current cluster leader node. + NodeInfo current_leader = 1; + + // Information about a cluster node. + message NodeInfo { + // The hostname or IP address of the node. + string host = 1; + + // The gRPC port of the node. + int32 port = 2; + + // The unique instance ID of the node. + optional string node_id = 3; + } +} diff --git a/src/KurrentDB.Client/Core/proto/kurrent/rpc/rpc.proto b/src/KurrentDB.Client/Core/proto/kurrent/rpc/rpc.proto new file mode 100644 index 000000000..73145fd06 --- /dev/null +++ b/src/KurrentDB.Client/Core/proto/kurrent/rpc/rpc.proto @@ -0,0 +1,74 @@ +// ****************************************************************************************** +// This protocol is UNSTABLE in the sense of being subject to change. +// ****************************************************************************************** + +syntax = "proto3"; + +package kurrent.rpc; + +option csharp_namespace = "Kurrent.Rpc"; + +import "google/protobuf/descriptor.proto"; +import "google/rpc/code.proto"; + +// ErrorMetadata provides actionable information for error enum values to enable automated +// code generation, documentation, and consistent error handling across the Kurrent platform. +// +// It was modeled to support a single details type per error code to simplify code generation and +// validation. If multiple detail types are needed for a single error code, consider defining +// separate error codes for each detail type. Or, use a union type (oneof) in the detail message +// to encapsulate multiple detail variants within a single detail message. +// +// More however DebugInfo and RetryInfo can and should be added to any error regardless of +// this setting, when applicable. +// +// This annotation is applied to enum values using the google.protobuf.EnumValueOptions +// extension mechanism. It enables: +// - Automatic gRPC status code mapping +// - Code generation for error handling utilities +// - Documentation generation +// - Type-safe error detail validation +// +// Usage Example: +// enum StreamErrorCode { +// REVISION_CONFLICT = 5 [(kurrent.rpc.error) = { +// status_code: FAILED_PRECONDITION, +// has_details: true +// }]; +// } +// +// See individual field documentation for conventions and defaults. +message ErrorMetadata { + // Maps the error to a standard gRPC status code for transport-level compatibility. + // This field is REQUIRED for every error annotation. + // + // Use standard gRPC status codes from `google.rpc.code`. + // + // Code generators use this to: + // - Map errors to gRPC status codes automatically + // - Generate HTTP status code mappings + // - Create transport-agnostic error handling + google.rpc.Code status_code = 1; + + // Indicates whether this error supports rich, typed detail messages. + // Defaults to false (simple message string only). + // The message type name must be derived from the enum name by convention. + // Mask: {EnumValue}ErrorDetails, {EnumValue}Error, {EnumValue} + // + // Examples: + // ACCESS_DENIED -> "AccessDeniedErrorDetails", "AccessDeniedError" or "AccessDenied" + // SERVER_NOT_READY -> "ServerNotReadyErrorDetails", "ServerNotReadyError" or "ServerNotReady" + // + // Code generators use the message type name to: + // - Validate that the detail message matches the expected type + // - Generate type-safe error handling code + // - Create accurate documentation + bool has_details = 2; +} + +// Extend EnumValueOptions to include error information for enum values +extend google.protobuf.EnumValueOptions { + // Provides additional information about error conditions for automated + // code generation and documentation. + optional ErrorMetadata error = 50000; +} diff --git a/src/KurrentDB.Client/Core/protos/gossip.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/gossip.proto similarity index 100% rename from src/KurrentDB.Client/Core/protos/gossip.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/gossip.proto diff --git a/src/KurrentDB.Client/Core/protos/operations.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/operations.proto similarity index 93% rename from src/KurrentDB.Client/Core/protos/operations.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/operations.proto index f4f9ae3c3..9d0f654bf 100644 --- a/src/KurrentDB.Client/Core/protos/operations.proto +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/operations.proto @@ -1,5 +1,9 @@ syntax = "proto3"; + package event_store.client.operations; + +option csharp_namespace = "KurrentDB.Protocol.Operations.V1"; + option java_package = "io.kurrent.client.operations"; import "shared.proto"; diff --git a/src/KurrentDB.Client/Core/protos/persistentsubscriptions.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/persistentsubscriptions.proto similarity index 98% rename from src/KurrentDB.Client/Core/protos/persistentsubscriptions.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/persistentsubscriptions.proto index ac63a3a6d..a1e9b55b8 100644 --- a/src/KurrentDB.Client/Core/protos/persistentsubscriptions.proto +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/persistentsubscriptions.proto @@ -1,6 +1,10 @@ syntax = "proto3"; + package event_store.client.persistent_subscriptions; -option java_package = "io.kurrent.dbclient.proto.persistentsubscriptions"; + +option csharp_namespace = "KurrentDB.Protocol.PersistentSubscriptions.V1"; + +option java_package = "io.kurrent.client.persistentsubscriptions"; import "shared.proto"; diff --git a/src/KurrentDB.Client/Core/protos/projectionmanagement.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/projectionmanagement.proto similarity index 97% rename from src/KurrentDB.Client/Core/protos/projectionmanagement.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/projectionmanagement.proto index f16877012..1c832ab9b 100644 --- a/src/KurrentDB.Client/Core/protos/projectionmanagement.proto +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/projectionmanagement.proto @@ -1,5 +1,9 @@ syntax = "proto3"; + package event_store.client.projections; + +option csharp_namespace = "KurrentDB.Protocol.Projections.V1"; + option java_package = "io.kurrent.client.projections"; import "google/protobuf/struct.proto"; diff --git a/src/KurrentDB.Client/Core/protos/serverfeatures.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/serverfeatures.proto similarity index 77% rename from src/KurrentDB.Client/Core/protos/serverfeatures.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/serverfeatures.proto index 61c4ab773..2bec769c0 100644 --- a/src/KurrentDB.Client/Core/protos/serverfeatures.proto +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/serverfeatures.proto @@ -1,6 +1,11 @@ syntax = "proto3"; + package event_store.client.server_features; -option java_package = "io.kurrent.dbclient.proto.serverfeatures"; + +option csharp_namespace = "KurrentDB.Protocol.Users.V1"; + +option java_package = "io.kurrent.client.serverfeatures"; + import "shared.proto"; service ServerFeatures { diff --git a/src/KurrentDB.Client/Core/protos/shared.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/shared.proto similarity index 95% rename from src/KurrentDB.Client/Core/protos/shared.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/shared.proto index 24780afc3..a785b7745 100644 --- a/src/KurrentDB.Client/Core/protos/shared.proto +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/shared.proto @@ -1,6 +1,6 @@ syntax = "proto3"; package event_store.client; -option java_package = "io.kurrent.dbclient.proto.shared"; +option java_package = "io.kurrent.client.proto.shared"; import "google/protobuf/empty.proto"; message UUID { diff --git a/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/streams.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/streams.proto new file mode 100644 index 000000000..0f51b23c5 --- /dev/null +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/streams.proto @@ -0,0 +1,357 @@ +syntax = "proto3"; + +package event_store.client.streams; + +option csharp_namespace = "KurrentDB.Protocol.Streams.V1"; + +option java_package = "com.eventstore.dbclient.proto.streams"; + +import "shared.proto"; +import "google/rpc/status.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +service Streams { + rpc Read (ReadReq) returns (stream ReadResp); + rpc Append (stream AppendReq) returns (AppendResp); + rpc Delete (DeleteReq) returns (DeleteResp); + rpc Tombstone (TombstoneReq) returns (TombstoneResp); + rpc BatchAppend (stream BatchAppendReq) returns (stream BatchAppendResp); +} + +message ReadReq { + Options options = 1; + + message Options { + oneof stream_option { + StreamOptions stream = 1; + AllOptions all = 2; + } + ReadDirection read_direction = 3; + bool resolve_links = 4; + oneof count_option { + uint64 count = 5; + SubscriptionOptions subscription = 6; + } + oneof filter_option { + FilterOptions filter = 7; + event_store.client.Empty no_filter = 8; + } + UUIDOption uuid_option = 9; + ControlOption control_option = 10; + + enum ReadDirection { + Forwards = 0; + Backwards = 1; + } + message StreamOptions { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof revision_option { + uint64 revision = 2; + event_store.client.Empty start = 3; + event_store.client.Empty end = 4; + } + } + message AllOptions { + oneof all_option { + Position position = 1; + event_store.client.Empty start = 2; + event_store.client.Empty end = 3; + } + } + message SubscriptionOptions { + } + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + message FilterOptions { + oneof filter { + Expression stream_identifier = 1; + Expression event_type = 2; + } + oneof window { + uint32 max = 3; + event_store.client.Empty count = 4; + } + uint32 checkpointIntervalMultiplier = 5; + + message Expression { + string regex = 1; + repeated string prefix = 2; + } + } + message UUIDOption { + oneof content { + event_store.client.Empty structured = 1; + event_store.client.Empty string = 2; + } + } + message ControlOption { + uint32 compatibility = 1; + } + } +} + +message ReadResp { + oneof content { + ReadEvent event = 1; + SubscriptionConfirmation confirmation = 2; + Checkpoint checkpoint = 3; + StreamNotFound stream_not_found = 4; + uint64 first_stream_position = 5; + uint64 last_stream_position = 6; + AllStreamPosition last_all_stream_position = 7; + CaughtUp caught_up = 8; + FellBehind fell_behind = 9; + } + + // The $all or stream subscription has caught up and become live. + message CaughtUp { + // Current time in the server when the subscription caught up + google.protobuf.Timestamp timestamp = 1; + + // Checkpoint for resuming a stream subscription. + // For stream subscriptions it is populated unless the stream is empty. + // For $all subscriptions it is not populated. + optional int64 stream_revision = 2; + + // Checkpoint for resuming a $all subscription. + // For stream subscriptions it is not populated. + // For $all subscriptions it is populated unless the database is empty. + optional Position position = 3; + } + + // The $all or stream subscription has fallen back into catchup mode and is no longer live. + message FellBehind { + // Current time in the server when the subscription fell behind + google.protobuf.Timestamp timestamp = 1; + + // Checkpoint for resuming a stream subscription. + // For stream subscriptions it is populated unless the stream is empty. + // For $all subscriptions it is not populated. + optional int64 stream_revision = 2; + + // Checkpoint for resuming a $all subscription. + // For stream subscriptions it is not populated. + // For $all subscriptions it is populated unless the database is empty. + optional Position position = 3; + } + + message ReadEvent { + RecordedEvent event = 1; + RecordedEvent link = 2; + oneof position { + uint64 commit_position = 3; + event_store.client.Empty no_position = 4; + } + + message RecordedEvent { + event_store.client.UUID id = 1; + event_store.client.StreamIdentifier stream_identifier = 2; + uint64 stream_revision = 3; + uint64 prepare_position = 4; + uint64 commit_position = 5; + map metadata = 6; + bytes custom_metadata = 7; + bytes data = 8; + } + } + message SubscriptionConfirmation { + string subscription_id = 1; + } + message Checkpoint { + uint64 commit_position = 1; + uint64 prepare_position = 2; + + // Current time in the server when the checkpoint was reached + google.protobuf.Timestamp timestamp = 3; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + + message StreamNotFound { + event_store.client.StreamIdentifier stream_identifier = 1; + } +} + +message AppendReq { + oneof content { + Options options = 1; + ProposedMessage proposed_message = 2; + } + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_revision { + uint64 revision = 2; + event_store.client.Empty no_stream = 3; + event_store.client.Empty any = 4; + event_store.client.Empty stream_exists = 5; + } + } + message ProposedMessage { + event_store.client.UUID id = 1; + map metadata = 2; + bytes custom_metadata = 3; + bytes data = 4; + } +} + +message AppendResp { + oneof result { + Success success = 1; + WrongExpectedVersion wrong_expected_version = 2; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } + + message Success { + oneof current_revision_option { + uint64 current_revision = 1; + event_store.client.Empty no_stream = 2; + } + oneof position_option { + Position position = 3; + event_store.client.Empty no_position = 4; + } + } + + message WrongExpectedVersion { + oneof current_revision_option_20_6_0 { + uint64 current_revision_20_6_0 = 1; + event_store.client.Empty no_stream_20_6_0 = 2; + } + oneof expected_revision_option_20_6_0 { + uint64 expected_revision_20_6_0 = 3; + event_store.client.Empty any_20_6_0 = 4; + event_store.client.Empty stream_exists_20_6_0 = 5; + } + oneof current_revision_option { + uint64 current_revision = 6; + event_store.client.Empty current_no_stream = 7; + } + oneof expected_revision_option { + uint64 expected_revision = 8; + event_store.client.Empty expected_any = 9; + event_store.client.Empty expected_stream_exists = 10; + event_store.client.Empty expected_no_stream = 11; + } + + } +} + +message BatchAppendReq { + event_store.client.UUID correlation_id = 1; + Options options = 2; + repeated ProposedMessage proposed_messages = 3; + bool is_final = 4; + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_position { + uint64 stream_position = 2; + google.protobuf.Empty no_stream = 3; + google.protobuf.Empty any = 4; + google.protobuf.Empty stream_exists = 5; + } + oneof deadline_option { + google.protobuf.Timestamp deadline_21_10_0 = 6; + google.protobuf.Duration deadline = 7; + } + } + + message ProposedMessage { + event_store.client.UUID id = 1; + map metadata = 2; + bytes custom_metadata = 3; + bytes data = 4; + } +} + +message BatchAppendResp { + event_store.client.UUID correlation_id = 1; + oneof result { + google.rpc.Status error = 2; + Success success = 3; + } + + event_store.client.StreamIdentifier stream_identifier = 4; + + oneof expected_stream_position { + uint64 stream_position = 5; + google.protobuf.Empty no_stream = 6; + google.protobuf.Empty any = 7; + google.protobuf.Empty stream_exists = 8; + } + + message Success { + oneof current_revision_option { + uint64 current_revision = 1; + google.protobuf.Empty no_stream = 2; + } + oneof position_option { + event_store.client.AllStreamPosition position = 3; + google.protobuf.Empty no_position = 4; + } + } +} + +message DeleteReq { + Options options = 1; + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_revision { + uint64 revision = 2; + event_store.client.Empty no_stream = 3; + event_store.client.Empty any = 4; + event_store.client.Empty stream_exists = 5; + } + } +} + +message DeleteResp { + oneof position_option { + Position position = 1; + event_store.client.Empty no_position = 2; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } +} + +message TombstoneReq { + Options options = 1; + + message Options { + event_store.client.StreamIdentifier stream_identifier = 1; + oneof expected_stream_revision { + uint64 revision = 2; + event_store.client.Empty no_stream = 3; + event_store.client.Empty any = 4; + event_store.client.Empty stream_exists = 5; + } + } +} + +message TombstoneResp { + oneof position_option { + Position position = 1; + event_store.client.Empty no_position = 2; + } + + message Position { + uint64 commit_position = 1; + uint64 prepare_position = 2; + } +} diff --git a/src/KurrentDB.Client/Core/protos/usermanagement.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/usermanagement.proto similarity index 97% rename from src/KurrentDB.Client/Core/protos/usermanagement.proto rename to src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/usermanagement.proto index 4b55251fd..85b465b32 100644 --- a/src/KurrentDB.Client/Core/protos/usermanagement.proto +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v1/usermanagement.proto @@ -1,5 +1,9 @@ syntax = "proto3"; + package event_store.client.users; + +option csharp_namespace = "KurrentDB.Protocol.Users.V1"; + option java_package = "io.kurrent.client.users"; service Users { diff --git a/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v2/streams/errors.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v2/streams/errors.proto new file mode 100644 index 000000000..5cb36f95b --- /dev/null +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v2/streams/errors.proto @@ -0,0 +1,213 @@ +// ****************************************************************************************** +// This protocol is UNSTABLE in the sense of being subject to change. +// ****************************************************************************************** + +syntax = "proto3"; + +package kurrentdb.protocol.v2.streams.errors; + +option csharp_namespace = "KurrentDB.Protocol.V2.Streams.Errors"; + +import "kurrent/rpc/rpc.proto"; + +// Error codes specific to the Streams API. +// These errors represent failure modes when working with streams of records. +enum StreamsError { + // Default value. This value is not used. + // An error code MUST always be set to a non-zero value. + // If an error code is not explicitly set, it MUST be treated as + // an internal server error (INTERNAL). + STREAMS_ERROR_UNSPECIFIED = 0; + + // The requested stream does not exist in the database. + // + // Common causes: + // - Stream name typo or incorrect stream identifier + // - Stream was never created (no events appended yet) + // - Stream was deleted and not yet recreated + // + // Client action: Verify the stream name is correct. Create the stream by appending to it. + // Recoverable by creating the stream first (append with NO_STREAM expected revision). + STREAMS_ERROR_STREAM_NOT_FOUND = 1 [(kurrent.rpc.error) = { + status_code: NOT_FOUND, + has_details: true, + }]; + + // The stream already exists when an operation expected it not to exist. + // + // Common causes: + // - Attempting to create a stream that already has events + // - Using NO_STREAM expected revision on an existing stream + // - Race condition with concurrent stream creation + // + // Client action: Use the existing stream or use a different expected revision. + // Recoverable by adjusting the expected revision or using the existing stream. + STREAMS_ERROR_STREAM_ALREADY_EXISTS = 2 [(kurrent.rpc.error) = { + status_code: ALREADY_EXISTS, + has_details: true + }]; + + // The stream has been soft deleted. + // Soft-deleted streams are hidden from stream lists but can be restored by appending to them. + // + // Common causes: + // - Stream was explicitly soft-deleted via delete operation + // - Attempting to read from a soft-deleted stream + // + // Client action: Restore the stream by appending new events, or accept that the stream is deleted. + // Recoverable by appending to the stream to restore it. + STREAMS_ERROR_STREAM_DELETED = 3 [(kurrent.rpc.error) = { + status_code: FAILED_PRECONDITION, + has_details: true + }]; + + // The stream has been tombstoned (permanently deleted). + // Tombstoned streams cannot be restored and will never accept new events. + // + // Common causes: + // - Stream was explicitly tombstoned via tombstone operation + // - Administrative deletion of sensitive data + // - Attempting to write to or read from a tombstoned stream + // + // Client action: Stream is permanently removed. Create a new stream with a different name if needed. + // Not recoverable - the stream cannot be restored. + STREAMS_ERROR_STREAM_TOMBSTONED = 4 [(kurrent.rpc.error) = { + status_code: FAILED_PRECONDITION, + has_details: true + }]; + + // The expected revision does not match the actual stream revision. + // This is an optimistic concurrency control failure. + // + // Common causes: + // - Another client modified the stream concurrently + // - Client has stale state about the stream revision + // - Race condition in distributed system + // + // Client action: Fetch the current stream revision and retry with the correct expected revision. + // Recoverable by reading the current state and retrying with proper optimistic concurrency control. + STREAMS_ERROR_STREAM_REVISION_CONFLICT = 5 [(kurrent.rpc.error) = { + status_code: FAILED_PRECONDITION, + has_details: true + }]; + + // A single record being appended exceeds the maximum allowed size. + // + // Common causes: + // - Record payload is too large (exceeds server's max record size configuration) + // - Excessive metadata in properties + // - Large binary data without chunking + // + // Client action: Reduce record size, split large payloads across multiple records, or increase server limits. + // Recoverable by reducing record size or adjusting server configuration. + STREAMS_ERROR_APPEND_RECORD_SIZE_EXCEEDED = 6 [(kurrent.rpc.error) = { + status_code: INVALID_ARGUMENT, + has_details: true + }]; + + // The total size of all records in a single append session exceeds the maximum allowed transaction size. + // + // Common causes: + // - Too many records in a single append session + // - Combined payload size exceeds server's max transaction size + // - Attempting to write very large batches + // + // Client action: Split the append into multiple smaller transactions. + // Recoverable by reducing the number of records per append session. + STREAMS_ERROR_APPEND_TRANSACTION_SIZE_EXCEEDED = 7 [(kurrent.rpc.error) = { + status_code: ABORTED, + has_details: true + }]; + + // The same stream appears multiple times in a single append session. + // This is currently not supported to prevent complexity with expected revisions and ordering. + // + // Common causes: + // - Accidentally appending to the same stream twice in one session + // - Application logic error in batch operations + // + // Client action: Remove duplicate streams from the append session or split into multiple sessions. + // Recoverable by restructuring the append session to reference each stream only once. + STREAMS_ERROR_STREAM_ALREADY_IN_APPEND_SESSION = 8 [(kurrent.rpc.error) = { + status_code: ABORTED, + has_details: true + }]; + + // An append session was started but no append requests were sent before completing the stream. + // + // Common causes: + // - Client completed the stream without sending any AppendRequest messages + // - Application logic error + // + // Client action: Ensure at least one AppendRequest is sent before completing the stream. + // Recoverable by properly implementing the append session protocol. + STREAMS_ERROR_APPEND_SESSION_NO_REQUESTS = 9 [(kurrent.rpc.error) = { + status_code: FAILED_PRECONDITION + }]; +} + +// Details for STREAM_NOT_FOUND errors. +message StreamNotFoundErrorDetails { + // The name of the stream that was not found. + string stream = 1; +} + +// Details for STREAM_ALREADY_EXISTS errors. +message StreamAlreadyExistsErrorDetails { + // The name of the stream that already exists. + string stream = 1; +} + +// Details for STREAM_DELETED errors. +message StreamDeletedErrorDetails { + // The name of the stream that was deleted. + string stream = 1; +} + +// Details for STREAM_TOMBSTONED errors. +message StreamTombstonedErrorDetails { + // The name of the stream that was tombstoned. + string stream = 1; +} + +// Details for STREAM_REVISION_CONFLICT errors. +message StreamRevisionConflictErrorDetails { + // The name of the stream that had a revision conflict. + string stream = 1; + + // The expected revision that was provided in the append request. + sint64 expected_revision = 2; + + // The actual current revision of the stream. + sint64 actual_revision = 3; +} + +// Details for APPEND_RECORD_SIZE_EXCEEDED errors. +message AppendRecordSizeExceededErrorDetails { + // The name of the stream where the append was attempted. + string stream = 1; + + // The identifier of the record that exceeded the size limit. + string record_id = 2; + + // The actual size of the record in bytes. + int32 size = 3; + + // The maximum allowed size of a single record in bytes. + int32 max_size = 4; +} + +// Details for APPEND_TRANSACTION_SIZE_EXCEEDED errors. +message AppendTransactionSizeExceededErrorDetails { + // The actual size of the transaction in bytes. + int32 size = 1; + + // The maximum allowed size of an append transaction in bytes. + int32 max_size = 2; +} + +// Details for STREAM_ALREADY_IN_APPEND_SESSION errors. +message StreamAlreadyInAppendSessionErrorDetails { + // The name of the stream that appears multiple times. + string stream = 1; +} diff --git a/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v2/streams/streams.proto b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v2/streams/streams.proto new file mode 100644 index 000000000..3592c60b1 --- /dev/null +++ b/src/KurrentDB.Client/Core/proto/kurrentdb/protocol/v2/streams/streams.proto @@ -0,0 +1,155 @@ +// ****************************************************************************************** +// This protocol is UNSTABLE in the sense of being subject to change. +// ****************************************************************************************** + +syntax = "proto3"; + +package kurrentdb.protocol.v2.streams; + +option csharp_namespace = "KurrentDB.Protocol.V2.Streams"; + +import "google/protobuf/struct.proto"; + +service StreamsService { + // Appends records to multiple streams atomically within a single transaction. + // + // This is a client-streaming RPC where the client sends multiple AppendRequest messages + // (one per stream) and receives a single AppendSessionResponse upon commit. + // + // Guarantees: + // - Atomicity: All writes succeed or all fail together + // - Optimistic Concurrency: Expected revisions are validated for all streams before commit + // - Ordering: Records within each stream maintain send order + // + // Current Limitations: + // - Each stream can only appear once per session (no multiple appends to same stream) + // + // Example flow: + // 1. Client opens stream + // 2. Client sends AppendRequest for stream "orders" with 3 records + // 3. Client sends AppendRequest for stream "inventory" with 2 records + // 4. Client completes the stream + // 5. Server validates, commits, returns AppendSessionResponse with positions + rpc AppendSession(stream AppendRequest) returns (AppendSessionResponse); +} + +// Represents the input for appending records to a specific stream. +message AppendRequest { + // The stream to append records to. + string stream = 1; + + // The records to append to the stream. + repeated AppendRecord records = 2; + + // The expected revision for optimistic concurrency control. + // Can be either: + // - A specific revision number (0, 1, 2, ...) - the stream must be at exactly this revision + // - An ExpectedRevisionConstants value (-4, -2, -1) for special semantics + // + // If omitted, defaults to EXPECTED_REVISION_CONSTANTS_ANY (-2). + optional sint64 expected_revision = 3; +} + +// Represents the outcome of an append operation. +message AppendResponse { + // The stream to which records were appended. + string stream = 1; + + // The actual/current revision of the stream after the append. + // This is the revision number of the last record written to this stream. + sint64 stream_revision = 2; + + // The position of the last appended record in the global log. + optional sint64 position = 3; +} + +message AppendSessionResponse { + // The results of each append request in the session. + repeated AppendResponse output = 1; + + // The global commit position of the last appended record in the session. + sint64 position = 2; +} + +// Represents the data format of the schema. +enum SchemaFormat { + // Default value, should not be used. + SCHEMA_FORMAT_UNSPECIFIED = 0; + SCHEMA_FORMAT_JSON = 1; + SCHEMA_FORMAT_PROTOBUF = 2; + SCHEMA_FORMAT_AVRO = 3; + SCHEMA_FORMAT_BYTES = 4; +} + +// Schema information for record validation and interpretation. +message SchemaInfo { + // The format of the data payload. + // Determines how the bytes in AppendRecord.data should be interpreted. + SchemaFormat format = 1; + + // The schema name (replaces the legacy "event type" concept). + // Identifies what kind of data this record contains. + // + // Common naming formats: + // - Kebab-case: "order-placed", "customer-registered" + // - URN format: "urn:kurrentdb:events:order-placed:v1" + // - Dotted namespace: "Teams.Player.V1", "Orders.OrderPlaced.V2" + // - Reverse domain: "com.acme.orders.placed" + string name = 2; + + // The identifier of the specific version of the schema that the record payload + // conforms to. This should match a registered schema version in the system. + // Not necessary when not enforcing schema validation. + optional string id = 3; +} + +// Record to be appended to a stream. +message AppendRecord { + // Unique identifier for this record (must be a valid UUID/GUID). + // If not provided, the server will generate a new one. + optional string record_id = 1; + + // A collection of properties providing additional information about the + // record. Can contain user-defined or system propreties. + // System keys will be prefixed with "$" (e.g., "$timestamp"). + // User-defined keys MUST NOT start with "$". + // + // Common examples: + // User metadata: + // - "user-id": "12345" + // - "tenant": "acme-corp" + // - "source": "mobile-app" + // + // System metadata (with $ prefix): + // - "$trace-id": "4bf92f3577b34da6a3ce929d0e0e4736" // OpenTelemetry trace ID + // - "$span-id": "00f067aa0ba902b7" // OpenTelemetry span ID + // - "$timestamp": "2025-01-15T10:30:00.000Z" // ISO 8601 timestamp + map properties = 2; + + // Schema information for this record. + SchemaInfo schema = 3; + + // The record payload as raw bytes. + // The format specified in SchemaInfo determines how to interpret these bytes. + bytes data = 4; +} + +// Constants for expected revision validation in optimistic concurrency control. +// These can be used in the expected_revision field, or you can specify an actual revision number. +enum ExpectedRevisionConstants { + // The stream must have exactly one event at revision 0. + // Used for scenarios requiring strict single-event semantics. + EXPECTED_REVISION_CONSTANTS_SINGLE_EVENT = 0; + + // The stream must not exist yet (first write to the stream). + // Fails if the stream already has events. + EXPECTED_REVISION_CONSTANTS_NO_STREAM = -1; + + // Accept any current state of the stream (no optimistic concurrency check). + // The write will succeed regardless of the stream's current revision. + EXPECTED_REVISION_CONSTANTS_ANY = -2; + + // The stream must exist (have at least one record). + // Fails if the stream doesn't exist yet. + EXPECTED_REVISION_CONSTANTS_EXISTS = -4; +} diff --git a/src/KurrentDB.Client/Core/protos/code.proto b/src/KurrentDB.Client/Core/protos/code.proto deleted file mode 100644 index 98ae0ac18..000000000 --- a/src/KurrentDB.Client/Core/protos/code.proto +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright 2020 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto3"; - -package google.rpc; - -option go_package = "google.golang.org/genproto/googleapis/rpc/code;code"; -option java_multiple_files = true; -option java_outer_classname = "CodeProto"; -option java_package = "com.google.rpc"; -option objc_class_prefix = "RPC"; - -// The canonical error codes for gRPC APIs. -// -// -// Sometimes multiple error codes may apply. Services should return -// the most specific error code that applies. For example, prefer -// `OUT_OF_RANGE` over `FAILED_PRECONDITION` if both codes apply. -// Similarly prefer `NOT_FOUND` or `ALREADY_EXISTS` over `FAILED_PRECONDITION`. -enum Code { - // Not an error; returned on success - // - // HTTP Mapping: 200 OK - OK = 0; - - // The operation was cancelled, typically by the caller. - // - // HTTP Mapping: 499 Client Closed Request - CANCELLED = 1; - - // Unknown error. For example, this error may be returned when - // a `Status` value received from another address space belongs to - // an error space that is not known in this address space. Also - // errors raised by APIs that do not return enough error information - // may be converted to this error. - // - // HTTP Mapping: 500 Internal Server Error - UNKNOWN = 2; - - // The client specified an invalid argument. Note that this differs - // from `FAILED_PRECONDITION`. `INVALID_ARGUMENT` indicates arguments - // that are problematic regardless of the state of the system - // (e.g., a malformed file name). - // - // HTTP Mapping: 400 Bad Request - INVALID_ARGUMENT = 3; - - // The deadline expired before the operation could complete. For operations - // that change the state of the system, this error may be returned - // even if the operation has completed successfully. For example, a - // successful response from a server could have been delayed long - // enough for the deadline to expire. - // - // HTTP Mapping: 504 Gateway Timeout - DEADLINE_EXCEEDED = 4; - - // Some requested entity (e.g., file or directory) was not found. - // - // Note to server developers: if a request is denied for an entire class - // of users, such as gradual feature rollout or undocumented whitelist, - // `NOT_FOUND` may be used. If a request is denied for some users within - // a class of users, such as user-based access control, `PERMISSION_DENIED` - // must be used. - // - // HTTP Mapping: 404 Not Found - NOT_FOUND = 5; - - // The entity that a client attempted to create (e.g., file or directory) - // already exists. - // - // HTTP Mapping: 409 Conflict - ALREADY_EXISTS = 6; - - // The caller does not have permission to execute the specified - // operation. `PERMISSION_DENIED` must not be used for rejections - // caused by exhausting some resource (use `RESOURCE_EXHAUSTED` - // instead for those errors). `PERMISSION_DENIED` must not be - // used if the caller can not be identified (use `UNAUTHENTICATED` - // instead for those errors). This error code does not imply the - // request is valid or the requested entity exists or satisfies - // other pre-conditions. - // - // HTTP Mapping: 403 Forbidden - PERMISSION_DENIED = 7; - - // The request does not have valid authentication credentials for the - // operation. - // - // HTTP Mapping: 401 Unauthorized - UNAUTHENTICATED = 16; - - // Some resource has been exhausted, perhaps a per-user quota, or - // perhaps the entire file system is out of space. - // - // HTTP Mapping: 429 Too Many Requests - RESOURCE_EXHAUSTED = 8; - - // The operation was rejected because the system is not in a state - // required for the operation's execution. For example, the directory - // to be deleted is non-empty, an rmdir operation is applied to - // a non-directory, etc. - // - // Service implementors can use the following guidelines to decide - // between `FAILED_PRECONDITION`, `ABORTED`, and `UNAVAILABLE`: - // (a) Use `UNAVAILABLE` if the client can retry just the failing call. - // (b) Use `ABORTED` if the client should retry at a higher level - // (e.g., when a client-specified test-and-set fails, indicating the - // client should restart a read-modify-write sequence). - // (c) Use `FAILED_PRECONDITION` if the client should not retry until - // the system state has been explicitly fixed. E.g., if an "rmdir" - // fails because the directory is non-empty, `FAILED_PRECONDITION` - // should be returned since the client should not retry unless - // the files are deleted from the directory. - // - // HTTP Mapping: 400 Bad Request - FAILED_PRECONDITION = 9; - - // The operation was aborted, typically due to a concurrency issue such as - // a sequencer check failure or transaction abort. - // - // See the guidelines above for deciding between `FAILED_PRECONDITION`, - // `ABORTED`, and `UNAVAILABLE`. - // - // HTTP Mapping: 409 Conflict - ABORTED = 10; - - // The operation was attempted past the valid range. E.g., seeking or - // reading past end-of-file. - // - // Unlike `INVALID_ARGUMENT`, this error indicates a problem that may - // be fixed if the system state changes. For example, a 32-bit file - // system will generate `INVALID_ARGUMENT` if asked to read at an - // offset that is not in the range [0,2^32-1], but it will generate - // `OUT_OF_RANGE` if asked to read from an offset past the current - // file size. - // - // There is a fair bit of overlap between `FAILED_PRECONDITION` and - // `OUT_OF_RANGE`. We recommend using `OUT_OF_RANGE` (the more specific - // error) when it applies so that callers who are iterating through - // a space can easily look for an `OUT_OF_RANGE` error to detect when - // they are done. - // - // HTTP Mapping: 400 Bad Request - OUT_OF_RANGE = 11; - - // The operation is not implemented or is not supported/enabled in this - // service. - // - // HTTP Mapping: 501 Not Implemented - UNIMPLEMENTED = 12; - - // Internal errors. This means that some invariants expected by the - // underlying system have been broken. This error code is reserved - // for serious errors. - // - // HTTP Mapping: 500 Internal Server Error - INTERNAL = 13; - - // The service is currently unavailable. This is most likely a - // transient condition, which can be corrected by retrying with - // a backoff. Note that it is not always safe to retry - // non-idempotent operations. - // - // See the guidelines above for deciding between `FAILED_PRECONDITION`, - // `ABORTED`, and `UNAVAILABLE`. - // - // HTTP Mapping: 503 Service Unavailable - UNAVAILABLE = 14; - - // Unrecoverable data loss or corruption. - // - // HTTP Mapping: 500 Internal Server Error - DATA_LOSS = 15; -} diff --git a/src/KurrentDB.Client/Core/protos/status.proto b/src/KurrentDB.Client/Core/protos/status.proto deleted file mode 100644 index 65eced268..000000000 --- a/src/KurrentDB.Client/Core/protos/status.proto +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2020 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto3"; - -package google.rpc; - -import "google/protobuf/any.proto"; -import "code.proto"; - -option cc_enable_arenas = true; -option go_package = "google.golang.org/genproto/googleapis/rpc/status;status"; -option java_multiple_files = true; -option java_outer_classname = "StatusProto"; -option java_package = "com.google.rpc"; -option objc_class_prefix = "RPC"; - -// The `Status` type defines a logical error model that is suitable for -// different programming environments, including REST APIs and RPC APIs. It is -// used by [gRPC](https://github.com/grpc). Each `Status` message contains -// three pieces of data: error code, error message, and error details. -// -// You can find out more about this error model and how to work with it in the -// [API Design Guide](https://cloud.google.com/apis/design/errors). -message Status { - // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. - google.rpc.Code code = 1; - - // A developer-facing error message, which should be in English. Any - // user-facing error message should be localized and sent in the - // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client. - string message = 2; - - // A list of messages that carry the error details. There is a common set of - // message types for APIs to use. - google.protobuf.Any details = 3; -} diff --git a/src/KurrentDB.Client/Core/protos/streams.proto b/src/KurrentDB.Client/Core/protos/streams.proto deleted file mode 100644 index 0eb05295c..000000000 --- a/src/KurrentDB.Client/Core/protos/streams.proto +++ /dev/null @@ -1,316 +0,0 @@ -syntax = "proto3"; -package event_store.client.streams; -option java_package = "io.kurrent.dbclient.proto.streams"; - -import "shared.proto"; -import "status.proto"; -import "google/protobuf/duration.proto"; -import "google/protobuf/empty.proto"; -import "google/protobuf/timestamp.proto"; - -service Streams { - rpc Read (ReadReq) returns (stream ReadResp); - rpc Append (stream AppendReq) returns (AppendResp); - rpc Delete (DeleteReq) returns (DeleteResp); - rpc Tombstone (TombstoneReq) returns (TombstoneResp); - rpc BatchAppend (stream BatchAppendReq) returns (stream BatchAppendResp); -} - -message ReadReq { - Options options = 1; - - message Options { - oneof stream_option { - StreamOptions stream = 1; - AllOptions all = 2; - } - ReadDirection read_direction = 3; - bool resolve_links = 4; - oneof count_option { - uint64 count = 5; - SubscriptionOptions subscription = 6; - } - oneof filter_option { - FilterOptions filter = 7; - event_store.client.Empty no_filter = 8; - } - UUIDOption uuid_option = 9; - ControlOption control_option = 10; - - enum ReadDirection { - Forwards = 0; - Backwards = 1; - } - message StreamOptions { - event_store.client.StreamIdentifier stream_identifier = 1; - oneof revision_option { - uint64 revision = 2; - event_store.client.Empty start = 3; - event_store.client.Empty end = 4; - } - } - message AllOptions { - oneof all_option { - Position position = 1; - event_store.client.Empty start = 2; - event_store.client.Empty end = 3; - } - } - message SubscriptionOptions { - } - message Position { - uint64 commit_position = 1; - uint64 prepare_position = 2; - } - message FilterOptions { - oneof filter { - Expression stream_identifier = 1; - Expression event_type = 2; - } - oneof window { - uint32 max = 3; - event_store.client.Empty count = 4; - } - uint32 checkpointIntervalMultiplier = 5; - - message Expression { - string regex = 1; - repeated string prefix = 2; - } - } - message UUIDOption { - oneof content { - event_store.client.Empty structured = 1; - event_store.client.Empty string = 2; - } - } - message ControlOption { - uint32 compatibility = 1; - } - } -} - -message ReadResp { - oneof content { - ReadEvent event = 1; - SubscriptionConfirmation confirmation = 2; - Checkpoint checkpoint = 3; - StreamNotFound stream_not_found = 4; - uint64 first_stream_position = 5; - uint64 last_stream_position = 6; - AllStreamPosition last_all_stream_position = 7; - CaughtUp caught_up = 8; - FellBehind fell_behind = 9; - } - - message CaughtUp {} - - message FellBehind {} - - message ReadEvent { - RecordedEvent event = 1; - RecordedEvent link = 2; - oneof position { - uint64 commit_position = 3; - event_store.client.Empty no_position = 4; - } - - message RecordedEvent { - event_store.client.UUID id = 1; - event_store.client.StreamIdentifier stream_identifier = 2; - uint64 stream_revision = 3; - uint64 prepare_position = 4; - uint64 commit_position = 5; - map metadata = 6; - bytes custom_metadata = 7; - bytes data = 8; - } - } - message SubscriptionConfirmation { - string subscription_id = 1; - } - message Checkpoint { - uint64 commit_position = 1; - uint64 prepare_position = 2; - } - message StreamNotFound { - event_store.client.StreamIdentifier stream_identifier = 1; - } -} - -message AppendReq { - oneof content { - Options options = 1; - ProposedMessage proposed_message = 2; - } - - message Options { - event_store.client.StreamIdentifier stream_identifier = 1; - oneof expected_stream_revision { - uint64 revision = 2; - event_store.client.Empty no_stream = 3; - event_store.client.Empty any = 4; - event_store.client.Empty stream_exists = 5; - } - } - message ProposedMessage { - event_store.client.UUID id = 1; - map metadata = 2; - bytes custom_metadata = 3; - bytes data = 4; - } -} - -message AppendResp { - oneof result { - Success success = 1; - WrongExpectedVersion wrong_expected_version = 2; - } - - message Position { - uint64 commit_position = 1; - uint64 prepare_position = 2; - } - - message Success { - oneof current_revision_option { - uint64 current_revision = 1; - event_store.client.Empty no_stream = 2; - } - oneof position_option { - Position position = 3; - event_store.client.Empty no_position = 4; - } - } - - message WrongExpectedVersion { - oneof current_revision_option_20_6_0 { - uint64 current_revision_20_6_0 = 1; - event_store.client.Empty no_stream_20_6_0 = 2; - } - oneof expected_revision_option_20_6_0 { - uint64 expected_revision_20_6_0 = 3; - event_store.client.Empty any_20_6_0 = 4; - event_store.client.Empty stream_exists_20_6_0 = 5; - } - oneof current_revision_option { - uint64 current_revision = 6; - event_store.client.Empty current_no_stream = 7; - } - oneof expected_revision_option { - uint64 expected_revision = 8; - event_store.client.Empty expected_any = 9; - event_store.client.Empty expected_stream_exists = 10; - event_store.client.Empty expected_no_stream = 11; - } - - } -} - -message BatchAppendReq { - event_store.client.UUID correlation_id = 1; - Options options = 2; - repeated ProposedMessage proposed_messages = 3; - bool is_final = 4; - - message Options { - event_store.client.StreamIdentifier stream_identifier = 1; - oneof expected_stream_position { - uint64 stream_position = 2; - google.protobuf.Empty no_stream = 3; - google.protobuf.Empty any = 4; - google.protobuf.Empty stream_exists = 5; - } - oneof deadline_option { - google.protobuf.Timestamp deadline_21_10_0 = 6; - google.protobuf.Duration deadline = 7; - } - } - - message ProposedMessage { - event_store.client.UUID id = 1; - map metadata = 2; - bytes custom_metadata = 3; - bytes data = 4; - } -} - -message BatchAppendResp { - event_store.client.UUID correlation_id = 1; - oneof result { - google.rpc.Status error = 2; - Success success = 3; - } - - event_store.client.StreamIdentifier stream_identifier = 4; - - oneof expected_stream_position { - uint64 stream_position = 5; - google.protobuf.Empty no_stream = 6; - google.protobuf.Empty any = 7; - google.protobuf.Empty stream_exists = 8; - } - - message Success { - oneof current_revision_option { - uint64 current_revision = 1; - google.protobuf.Empty no_stream = 2; - } - oneof position_option { - event_store.client.AllStreamPosition position = 3; - google.protobuf.Empty no_position = 4; - } - } -} - -message DeleteReq { - Options options = 1; - - message Options { - event_store.client.StreamIdentifier stream_identifier = 1; - oneof expected_stream_revision { - uint64 revision = 2; - event_store.client.Empty no_stream = 3; - event_store.client.Empty any = 4; - event_store.client.Empty stream_exists = 5; - } - } -} - -message DeleteResp { - oneof position_option { - Position position = 1; - event_store.client.Empty no_position = 2; - } - - message Position { - uint64 commit_position = 1; - uint64 prepare_position = 2; - } -} - -message TombstoneReq { - Options options = 1; - - message Options { - event_store.client.StreamIdentifier stream_identifier = 1; - oneof expected_stream_revision { - uint64 revision = 2; - event_store.client.Empty no_stream = 3; - event_store.client.Empty any = 4; - event_store.client.Empty stream_exists = 5; - } - } -} - -message TombstoneResp { - oneof position_option { - Position position = 1; - event_store.client.Empty no_position = 2; - } - - message Position { - uint64 commit_position = 1; - uint64 prepare_position = 2; - } -} diff --git a/src/KurrentDB.Client/KurrentDB.Client.csproj b/src/KurrentDB.Client/KurrentDB.Client.csproj index 95d465f45..3cf633376 100644 --- a/src/KurrentDB.Client/KurrentDB.Client.csproj +++ b/src/KurrentDB.Client/KurrentDB.Client.csproj @@ -8,90 +8,53 @@ - + - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - + + + - - - - - - - - + + + + + + diff --git a/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Admin.cs b/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Admin.cs index 2e3738c27..3d9146dca 100644 --- a/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Admin.cs +++ b/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Admin.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.Operations; +using KurrentDB.Protocol.Operations.V1; +using static KurrentDB.Protocol.Operations.V1.Operations; namespace KurrentDB.Client { public partial class KurrentDBOperationsClient { @@ -17,7 +18,7 @@ public async Task ShutdownAsync( UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Operations.OperationsClient( + using var call = new OperationsClient( channelInfo.CallInvoker).ShutdownAsync(EmptyResult, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); await call.ResponseAsync.ConfigureAwait(false); @@ -35,7 +36,7 @@ public async Task MergeIndexesAsync( UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Operations.OperationsClient( + using var call = new OperationsClient( channelInfo.CallInvoker).MergeIndexesAsync(EmptyResult, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); await call.ResponseAsync.ConfigureAwait(false); @@ -53,7 +54,7 @@ public async Task ResignNodeAsync( UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Operations.OperationsClient( + using var call = new OperationsClient( channelInfo.CallInvoker).ResignNodeAsync(EmptyResult, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); await call.ResponseAsync.ConfigureAwait(false); @@ -72,7 +73,7 @@ public async Task SetNodePriorityAsync(int nodePriority, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Operations.OperationsClient( + using var call = new OperationsClient( channelInfo.CallInvoker).SetNodePriorityAsync( new SetNodePriorityReq {Priority = nodePriority}, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); @@ -91,7 +92,7 @@ public async Task RestartPersistentSubscriptions( UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Operations.OperationsClient( + using var call = new OperationsClient( channelInfo.CallInvoker).RestartPersistentSubscriptionsAsync( EmptyResult, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); diff --git a/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Scavenge.cs b/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Scavenge.cs index fc1cbaafd..a0691418e 100644 --- a/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Scavenge.cs +++ b/src/KurrentDB.Client/Operations/KurrentDBOperationsClient.Scavenge.cs @@ -1,4 +1,5 @@ -using EventStore.Client.Operations; +using KurrentDB.Protocol.Operations.V1; +using static KurrentDB.Protocol.Operations.V1.Operations; namespace KurrentDB.Client { public partial class KurrentDBOperationsClient { @@ -27,7 +28,7 @@ public async Task StartScavengeAsync( } var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Operations.OperationsClient( + using var call = new OperationsClient( channelInfo.CallInvoker).StartScavengeAsync( new StartScavengeReq { Options = new StartScavengeReq.Types.Options { @@ -60,7 +61,7 @@ public async Task StopScavengeAsync( UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - var result = await new Operations.OperationsClient( + var result = await new OperationsClient( channelInfo.CallInvoker).StopScavengeAsync(new StopScavengeReq { Options = new StopScavengeReq.Types.Options { ScavengeId = scavengeId diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Create.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Create.cs index 4b82c53f5..7f4e76e1f 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Create.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Create.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; namespace KurrentDB.Client { partial class KurrentDBPersistentSubscriptionsClient { @@ -9,7 +10,7 @@ partial class KurrentDBPersistentSubscriptionsClient { [SystemConsumerStrategies.RoundRobin] = CreateReq.Types.ConsumerStrategy.RoundRobin, [SystemConsumerStrategies.Pinned] = CreateReq.Types.ConsumerStrategy.Pinned, }; - + private static CreateReq.Types.StreamOptions StreamOptionsForCreateProto(string streamName, StreamPosition position) { if (position == StreamPosition.Start) { return new CreateReq.Types.StreamOptions { @@ -202,7 +203,7 @@ private async Task CreateInternalAsync(string streamName, string groupName, IEve throw new InvalidOperationException("The server does not support persistent subscriptions to $all."); } - using var call = new PersistentSubscriptions.PersistentSubscriptionsClient( + using var call = new PersistentSubscriptionsClient( channelInfo.CallInvoker).CreateAsync(new CreateReq { Options = new CreateReq.Types.Options { Stream = streamName != SystemStreams.AllStream diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Delete.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Delete.cs index 252cf27a5..5a94ebdae 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Delete.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Delete.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; namespace KurrentDB.Client { partial class KurrentDBPersistentSubscriptionsClient { @@ -34,7 +35,7 @@ public async Task DeleteToStreamAsync(string streamName, string groupName, TimeS } using var call = - new PersistentSubscriptions.PersistentSubscriptionsClient( + new PersistentSubscriptionsClient( channelInfo.CallInvoker) .DeleteAsync(new DeleteReq {Options = deleteOptions}, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Info.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Info.cs index 5c501c1e4..d5c5504b2 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Info.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Info.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; using Grpc.Core; #nullable enable @@ -53,7 +54,7 @@ public async Task GetInfoToStreamAsync(string stream private async Task GetInfoGrpcAsync(GetInfoReq req, TimeSpan? deadline, UserCredentials? userCredentials, CallInvoker callInvoker, CancellationToken cancellationToken) { - var result = await new PersistentSubscriptions.PersistentSubscriptionsClient(callInvoker) + var result = await new PersistentSubscriptionsClient(callInvoker) .GetInfoAsync(req, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) .ConfigureAwait(false); diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.List.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.List.cs index 4d29c6579..7736c05e9 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.List.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.List.cs @@ -1,6 +1,7 @@ using EventStore.Client; using Grpc.Core; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; #nullable enable namespace KurrentDB.Client { @@ -85,7 +86,7 @@ public async Task> ListAllAsync(TimeSpan private async Task> ListGrpcAsync(ListReq req, TimeSpan? deadline, UserCredentials? userCredentials, CallInvoker callInvoker, CancellationToken cancellationToken) { - using var call = new PersistentSubscriptions.PersistentSubscriptionsClient(callInvoker) + using var call = new PersistentSubscriptionsClient(callInvoker) .ListAsync(req, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); ListResp? response = await call.ResponseAsync.ConfigureAwait(false); diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Read.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Read.cs index d90f9d5c6..94d93464f 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Read.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Read.cs @@ -1,9 +1,10 @@ using System.Threading.Channels; using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; using KurrentDB.Client.Diagnostics; using Grpc.Core; -using static EventStore.Client.PersistentSubscriptions.ReadResp.ContentOneofCase; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.ReadResp.ContentOneofCase; namespace KurrentDB.Client { partial class KurrentDBPersistentSubscriptionsClient { @@ -236,7 +237,7 @@ CancellationToken cancellationToken cancellationToken: cancellationToken ); - _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + _channel = System.Threading.Channels.Channel.CreateBounded(ReadBoundedChannelOptions); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -247,7 +248,7 @@ CancellationToken cancellationToken async Task PumpMessages() { try { var channelInfo = await selectChannelInfo(_cts.Token).ConfigureAwait(false); - var client = new PersistentSubscriptions.PersistentSubscriptionsClient(channelInfo.CallInvoker); + var client = new PersistentSubscriptionsClient(channelInfo.CallInvoker); _call = client.Read(_callOptions); diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.ReplayParked.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.ReplayParked.cs index 3dcf3b77e..7627ad2d1 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.ReplayParked.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.ReplayParked.cs @@ -1,6 +1,7 @@ using Grpc.Core; using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; using NotSupportedException = System.NotSupportedException; #nullable enable @@ -72,7 +73,7 @@ private async Task ReplayParkedGrpcAsync(ReplayParkedReq req, long? numberOfEven req.Options.NoLimit = new Empty(); } - await new PersistentSubscriptions.PersistentSubscriptionsClient(callInvoker) + await new PersistentSubscriptionsClient(callInvoker) .ReplayParkedAsync(req, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) .ConfigureAwait(false); } diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.RestartSubsystem.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.RestartSubsystem.cs index 4139aa5bf..67468b8a2 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.RestartSubsystem.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.RestartSubsystem.cs @@ -1,6 +1,6 @@ #nullable enable using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; namespace KurrentDB.Client { partial class KurrentDBPersistentSubscriptionsClient { @@ -12,7 +12,7 @@ public async Task RestartSubsystemAsync(TimeSpan? deadline = null, UserCredentia var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); if (channelInfo.ServerCapabilities.SupportsPersistentSubscriptionsRestartSubsystem) { - await new PersistentSubscriptions.PersistentSubscriptionsClient(channelInfo.CallInvoker) + await new PersistentSubscriptionsClient(channelInfo.CallInvoker) .RestartSubsystemAsync(new Empty(), KurrentDBCallOptions .CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)) .ConfigureAwait(false); diff --git a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Update.cs b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Update.cs index 2905a8b9a..37a42686f 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Update.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/KurrentDBPersistentSubscriptionsClient.Update.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; +using static KurrentDB.Protocol.PersistentSubscriptions.V1.PersistentSubscriptions; namespace KurrentDB.Client { public partial class KurrentDBPersistentSubscriptionsClient { @@ -106,7 +107,7 @@ public async Task UpdateToStreamAsync(string streamName, string groupName, Persi throw new InvalidOperationException("The server does not support persistent subscriptions to $all."); } - using var call = new PersistentSubscriptions.PersistentSubscriptionsClient(channelInfo.CallInvoker) + using var call = new PersistentSubscriptionsClient(channelInfo.CallInvoker) .UpdateAsync(new UpdateReq { Options = new UpdateReq.Types.Options { GroupName = groupName, diff --git a/src/KurrentDB.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs b/src/KurrentDB.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs index ca4599452..0b78536fc 100644 --- a/src/KurrentDB.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs +++ b/src/KurrentDB.Client/PersistentSubscriptions/PersistentSubscriptionInfo.cs @@ -1,5 +1,5 @@ using Google.Protobuf.Collections; -using EventStore.Client.PersistentSubscriptions; +using KurrentDB.Protocol.PersistentSubscriptions.V1; namespace KurrentDB.Client { /// diff --git a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Control.cs b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Control.cs index f2e8aa82f..9036758d5 100644 --- a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Control.cs +++ b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Control.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.Projections; +using KurrentDB.Protocol.Projections.V1; +using static KurrentDB.Protocol.Projections.V1.Projections; namespace KurrentDB.Client { public partial class KurrentDBProjectionManagementClient { @@ -14,7 +15,7 @@ public partial class KurrentDBProjectionManagementClient { public async Task EnableAsync(string name, TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).EnableAsync(new EnableReq { Options = new EnableReq.Types.Options { Name = name @@ -34,7 +35,7 @@ public async Task EnableAsync(string name, TimeSpan? deadline = null, UserCreden public async Task ResetAsync(string name, TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).ResetAsync(new ResetReq { Options = new ResetReq.Types.Options { Name = name, @@ -78,7 +79,7 @@ public Task DisableAsync(string name, TimeSpan? deadline = null, UserCredentials public async Task RestartSubsystemAsync(TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).RestartSubsystemAsync(new Empty(), KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); await call.ResponseAsync.ConfigureAwait(false); @@ -87,7 +88,7 @@ public async Task RestartSubsystemAsync(TimeSpan? deadline = null, UserCredentia private async Task DisableInternalAsync(string name, bool writeCheckpoint, TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).DisableAsync(new DisableReq { Options = new DisableReq.Types.Options { Name = name, diff --git a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Create.cs b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Create.cs index 0d70d7e0e..e98794e22 100644 --- a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Create.cs +++ b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Create.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.Projections; +using KurrentDB.Protocol.Projections.V1; +using static KurrentDB.Protocol.Projections.V1.Projections; namespace KurrentDB.Client { public partial class KurrentDBProjectionManagementClient { @@ -14,7 +15,7 @@ public partial class KurrentDBProjectionManagementClient { public async Task CreateOneTimeAsync(string query, TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).CreateAsync(new CreateReq { Options = new CreateReq.Types.Options { OneTime = new Empty(), @@ -38,7 +39,7 @@ public async Task CreateContinuousAsync(string name, string query, bool trackEmi TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).CreateAsync(new CreateReq { Options = new CreateReq.Types.Options { Continuous = new CreateReq.Types.Options.Types.Continuous { @@ -63,7 +64,7 @@ public async Task CreateContinuousAsync(string name, string query, bool trackEmi public async Task CreateTransientAsync(string name, string query, TimeSpan? deadline = null, UserCredentials? userCredentials = null, CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).CreateAsync(new CreateReq { Options = new CreateReq.Types.Options { Transient = new CreateReq.Types.Options.Types.Transient { diff --git a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.State.cs b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.State.cs index e5697bf19..809ac7c8c 100644 --- a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.State.cs +++ b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.State.cs @@ -1,6 +1,7 @@ using System.Text.Json; -using EventStore.Client.Projections; using Google.Protobuf.WellKnownTypes; +using KurrentDB.Protocol.Projections.V1; +using static KurrentDB.Protocol.Projections.V1.Projections; using Type = System.Type; namespace KurrentDB.Client { @@ -70,7 +71,7 @@ public async Task GetResultAsync(string name, string? partition = null, private async ValueTask GetResultInternalAsync(string name, string? partition, TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).ResultAsync(new ResultReq { Options = new ResultReq.Types.Options { Name = name, @@ -145,7 +146,7 @@ public async Task GetStateAsync(string name, string? partition = null, private async ValueTask GetStateInternalAsync(string name, string? partition, TimeSpan? deadline, UserCredentials? userCredentials, CancellationToken cancellationToken) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).StateAsync(new StateReq { Options = new StateReq.Types.Options { Name = name, diff --git a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Statistics.cs b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Statistics.cs index d61d3b9e8..e7319f082 100644 --- a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Statistics.cs +++ b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Statistics.cs @@ -1,7 +1,8 @@ using System.Runtime.CompilerServices; using EventStore.Client; -using EventStore.Client.Projections; using Grpc.Core; +using KurrentDB.Protocol.Projections.V1; +using static KurrentDB.Protocol.Projections.V1.Projections; namespace KurrentDB.Client { public partial class KurrentDBProjectionManagementClient { @@ -72,7 +73,7 @@ private async IAsyncEnumerable ListInternalAsync(StatisticsRe CallOptions callOptions, [EnumeratorCancellation] CancellationToken cancellationToken) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).Statistics(new StatisticsReq { Options = options }, callOptions); diff --git a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Update.cs b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Update.cs index 649179ce7..a1a511f86 100644 --- a/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Update.cs +++ b/src/KurrentDB.Client/ProjectionManagement/KurrentDBProjectionManagementClient.Update.cs @@ -1,5 +1,6 @@ using EventStore.Client; -using EventStore.Client.Projections; +using KurrentDB.Protocol.Projections.V1; +using static KurrentDB.Protocol.Projections.V1.Projections; namespace KurrentDB.Client { public partial class KurrentDBProjectionManagementClient { @@ -27,7 +28,7 @@ public async Task UpdateAsync(string name, string query, bool? emitEnabled = nul } var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Projections.ProjectionsClient( + using var call = new ProjectionsClient( channelInfo.CallInvoker).UpdateAsync(new UpdateReq { Options = options }, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); diff --git a/src/KurrentDB.Client/Streams/AppendRecordSizeExceededException.cs b/src/KurrentDB.Client/Streams/AppendRecordSizeExceededException.cs new file mode 100644 index 000000000..98a231e05 --- /dev/null +++ b/src/KurrentDB.Client/Streams/AppendRecordSizeExceededException.cs @@ -0,0 +1,33 @@ +using System; + +namespace KurrentDB.Client { + /// + /// Exception thrown when an append exceeds the maximum size set by the server. + /// + public class MaximumAppendSizeExceededException : Exception { + /// + /// The configured maximum append size. + /// + public uint MaxAppendSize { get; } + + /// + /// Constructs a new . + /// + /// + /// + public MaximumAppendSizeExceededException(uint maxAppendSize, Exception? innerException = null) : + base($"Maximum Append Size of {maxAppendSize} Exceeded.", innerException) { + MaxAppendSize = maxAppendSize; + } + + /// + /// Constructs a new . + /// + /// + /// + public MaximumAppendSizeExceededException(int maxAppendSize, Exception? innerException = null) : this( + (uint)maxAppendSize, innerException) { + + } + } +} diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.Append.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.Append.cs index 66563d2ba..8f61f0e95 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.Append.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.Append.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Threading.Channels; -using EventStore.Client.Streams; using Google.Protobuf; using Grpc.Core; using Microsoft.Extensions.Logging; @@ -9,6 +8,8 @@ using KurrentDB.Diagnostics.Telemetry; using KurrentDB.Diagnostics.Tracing; using KurrentDB.Client.Diagnostics; +using KurrentDB.Protocol.Streams.V1; +using static KurrentDB.Protocol.Streams.V1.Streams; namespace KurrentDB.Client { public partial class KurrentDBClient { @@ -75,7 +76,7 @@ CancellationToken cancellationToken return KurrentDBClientDiagnostics.ActivitySource.TraceClientOperation(Operation, TracingConstants.Operations.Append, tags); async ValueTask Operation() { - using var call = new Streams.StreamsClient(channelInfo.CallInvoker) + using var call = new StreamsClient(channelInfo.CallInvoker) .Append(KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); await call.RequestStream @@ -201,7 +202,7 @@ Action onException _settings = settings; _cancellationToken = cancellationToken; _onException = onException; - _channel = Channel.CreateBounded(10000); + _channel = System.Threading.Channels.Channel.CreateBounded(10000); _pendingRequests = new ConcurrentDictionary>(); _isUsable = new TaskCompletionSource(); @@ -265,7 +266,7 @@ async Task Duplex(ValueTask channelInfoTask) { return; } - _call = new Streams.StreamsClient(_channelInfo.CallInvoker).BatchAppend( + _call = new StreamsClient(_channelInfo.CallInvoker).BatchAppend( KurrentDBCallOptions.CreateStreaming( _settings, userCredentials: _settings.DefaultCredentials, diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.Delete.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.Delete.cs index 1e331a2bf..702b1b8d5 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.Delete.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.Delete.cs @@ -1,5 +1,6 @@ -using EventStore.Client.Streams; +using KurrentDB.Protocol.Streams.V1; using Microsoft.Extensions.Logging; +using static KurrentDB.Protocol.Streams.V1.Streams; namespace KurrentDB.Client { public partial class KurrentDBClient { @@ -29,7 +30,7 @@ private async Task DeleteInternal(DeleteReq request, CancellationToken cancellationToken) { _log.LogDebug("Deleting stream {streamName}.", request.Options.StreamIdentifier); var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Streams.StreamsClient( + using var call = new StreamsClient( channelInfo.CallInvoker).DeleteAsync(request, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); var result = await call.ResponseAsync.ConfigureAwait(false); diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.Metadata.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.Metadata.cs index 629d12ced..2541257c0 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.Metadata.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.Metadata.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using EventStore.Client.Streams; +using KurrentDB.Protocol.Streams.V1; using Microsoft.Extensions.Logging; namespace KurrentDB.Client { diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.MultiAppend.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.MultiAppend.cs new file mode 100644 index 000000000..ca2c1cb64 --- /dev/null +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.MultiAppend.cs @@ -0,0 +1,93 @@ +// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault +// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault +// ReSharper disable ConvertToPrimaryConstructor +// ReSharper disable PossibleMultipleEnumeration + +#pragma warning disable CS8509 // The switch expression does not handle all possible values of its input type (it is not exhaustive). + +using System.Diagnostics; +using Google.Rpc; +using Grpc.Core; +using KurrentDB.Client.Diagnostics; +using KurrentDB.Diagnostics; +using KurrentDB.Diagnostics.Telemetry; +using KurrentDB.Protocol.V2.Streams; +using static KurrentDB.Diagnostics.Tracing.TracingConstants; +using static KurrentDB.Protocol.V2.Streams.StreamsService; + +namespace KurrentDB.Client; + +public partial class KurrentDBClient { + /// + /// Appends events to multiple streams asynchronously, ensuring the specified state of each stream is respected. + /// + /// An asynchronous enumerable of objects, each containing details of the stream, expected stream state, and events to append. + /// An optional cancellation token to observe while waiting for the operation to complete. + /// + /// A task that represents the asynchronous operation, with a result of type , indicating the outcome of the operation. + /// + /// On success, returns containing the successful append results. + /// , , , ., or . + /// + /// + /// Thrown if the server does not support multi-stream append functionality (requires server version 25.1 or higher). + public async ValueTask MultiStreamAppendAsync( + IAsyncEnumerable requests, CancellationToken cancellationToken = default + ) { + var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); + + if (!channelInfo.ServerCapabilities.SupportsMultiStreamAppend) + throw new InvalidOperationException($"{nameof(MultiStreamAppendAsync)} requires server version 25.1 or higher."); + + var client = new StreamsServiceClient(channelInfo.CallInvoker); + + var tags = new ActivityTagsCollection() + .WithGrpcChannelServerTags(channelInfo) + .WithClientSettingsServerTags(Settings) + .WithOptionalTag(TelemetryTags.Database.User, Settings.DefaultCredentials?.Username); + + return await KurrentDBClientDiagnostics.ActivitySource.TraceClientOperation(Operation, Operations.MultiAppend, tags).ConfigureAwait(false); + + async ValueTask Operation() { + try { + using var session = client.AppendSession(KurrentDBCallOptions.CreateStreaming(Settings, cancellationToken: cancellationToken)); + + await foreach (var request in requests.WithCancellation(cancellationToken)) { + var records = await request.Messages + .Map() + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + + var serviceRequest = new AppendRequest { + Stream = request.Stream, + ExpectedRevision = request.ExpectedState.ToInt64(), + Records = { records } + }; + + // Cancellation of stream writes is not supported by this gRPC implementation. + // To cancel the operation, we should cancel the entire session. + await session.RequestStream + .WriteAsync(serviceRequest) + .ConfigureAwait(false); + } + + await session.RequestStream.CompleteAsync(); + + var response = await session.ResponseAsync; + + var responses = response.Output.Select(appendResponse => new AppendResponse(appendResponse.Stream, appendResponse.StreamRevision)); + + return new MultiStreamAppendResponse(response.Position, responses); + } catch (RpcException ex) { + var status = ex.GetRpcStatus()!; + throw status.GetDetail() switch { + { Reason: "STREAM_REVISION_CONFLICT" } => WrongExpectedVersionException.FromRpcException(ex), + { Reason: "STREAM_TOMBSTONED" } => StreamTombstonedException.FromRpcException(ex), + { Reason: "APPEND_RECORD_SIZE_EXCEEDED" } => AppendRecordSizeExceededException.FromRpcException(ex), + { Reason: "APPEND_TRANSACTION_SIZE_EXCEEDED" } => AppendTransactionMaxSizeExceededException.FromRpcException(ex), + _ => ex + }; + } + } + } +} diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.Read.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.Read.cs index 2347550b4..aa01d994e 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.Read.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.Read.cs @@ -1,7 +1,8 @@ using System.Threading.Channels; -using EventStore.Client.Streams; using Grpc.Core; -using static EventStore.Client.Streams.ReadResp.ContentOneofCase; +using KurrentDB.Protocol.Streams.V1; +using static KurrentDB.Protocol.Streams.V1.Streams; +using static KurrentDB.Protocol.Streams.V1.ReadResp.ContentOneofCase; namespace KurrentDB.Client { public partial class KurrentDBClient { @@ -149,7 +150,7 @@ CancellationToken cancellationToken cancellationToken ); - _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + _channel = System.Threading.Channels.Channel.CreateBounded(ReadBoundedChannelOptions); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var linkedCancellationToken = _cts.Token; @@ -164,7 +165,7 @@ CancellationToken cancellationToken async Task PumpMessages() { try { var callInvoker = await selectCallInvoker(linkedCancellationToken).ConfigureAwait(false); - var client = new Streams.StreamsClient(callInvoker); + var client = new StreamsClient(callInvoker); using var call = client.Read(request, callOptions); await foreach (var response in call.ResponseStream.ReadAllAsync(linkedCancellationToken) .ConfigureAwait(false)) { @@ -348,7 +349,7 @@ CancellationToken cancellationToken cancellationToken ); - _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + _channel = System.Threading.Channels.Channel.CreateBounded(ReadBoundedChannelOptions); StreamName = request.Options.Stream.StreamIdentifier!; @@ -368,7 +369,7 @@ async Task PumpMessages() { try { var callInvoker = await selectCallInvoker(linkedCancellationToken).ConfigureAwait(false); - var client = new Streams.StreamsClient(callInvoker); + var client = new StreamsClient(callInvoker); using var call = client.Read(request, callOptions); await foreach (var response in call.ResponseStream.ReadAllAsync(linkedCancellationToken) diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.Subscriptions.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.Subscriptions.cs index 4824b438d..9647d39de 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.Subscriptions.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.Subscriptions.cs @@ -1,8 +1,9 @@ using System.Threading.Channels; using KurrentDB.Client.Diagnostics; -using EventStore.Client.Streams; using Grpc.Core; -using static EventStore.Client.Streams.ReadResp.ContentOneofCase; +using KurrentDB.Protocol.Streams.V1; +using static KurrentDB.Protocol.Streams.V1.ReadResp.ContentOneofCase; +using static KurrentDB.Protocol.Streams.V1.Streams; namespace KurrentDB.Client { public partial class KurrentDBClient { @@ -186,7 +187,7 @@ CancellationToken cancellationToken cancellationToken: cancellationToken ); - _channel = Channel.CreateBounded(ReadBoundedChannelOptions); + _channel = System.Threading.Channels.Channel.CreateBounded(ReadBoundedChannelOptions); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -201,7 +202,7 @@ CancellationToken cancellationToken async Task PumpMessages() { try { var channelInfo = await selectChannelInfo(_cts.Token).ConfigureAwait(false); - var client = new Streams.StreamsClient(channelInfo.CallInvoker); + var client = new StreamsClient(channelInfo.CallInvoker); _call = client.Read(_request, _callOptions); await foreach (var response in _call.ResponseStream.ReadAllAsync(_cts.Token).ConfigureAwait(false)) { StreamMessage subscriptionMessage = diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.Tombstone.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.Tombstone.cs index 4fd3d2db2..bd1c7bdf7 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.Tombstone.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.Tombstone.cs @@ -1,5 +1,6 @@ -using EventStore.Client.Streams; +using KurrentDB.Protocol.Streams.V1; using Microsoft.Extensions.Logging; +using static KurrentDB.Protocol.Streams.V1.Streams; namespace KurrentDB.Client { public partial class KurrentDBClient { @@ -28,7 +29,7 @@ private async Task TombstoneInternal(TombstoneReq request, TimeSpa _log.LogDebug("Tombstoning stream {streamName}.", request.Options.StreamIdentifier); var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Streams.StreamsClient( + using var call = new StreamsClient( channelInfo.CallInvoker).TombstoneAsync(request, KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); var result = await call.ResponseAsync.ConfigureAwait(false); diff --git a/src/KurrentDB.Client/Streams/KurrentDBClient.cs b/src/KurrentDB.Client/Streams/KurrentDBClient.cs index 9163cf1f8..a4cdfa947 100644 --- a/src/KurrentDB.Client/Streams/KurrentDBClient.cs +++ b/src/KurrentDB.Client/Streams/KurrentDBClient.cs @@ -2,10 +2,10 @@ using System.Threading.Channels; using EventStore.Client; using Grpc.Core; +using KurrentDB.Protocol.Streams.V1; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using ReadReq = EventStore.Client.Streams.ReadReq; namespace KurrentDB.Client { /// diff --git a/src/KurrentDB.Client/Streams/MaximumAppendSizeExceededException.cs b/src/KurrentDB.Client/Streams/MaximumAppendSizeExceededException.cs index 98a231e05..3ec0dab13 100644 --- a/src/KurrentDB.Client/Streams/MaximumAppendSizeExceededException.cs +++ b/src/KurrentDB.Client/Streams/MaximumAppendSizeExceededException.cs @@ -1,33 +1,58 @@ using System; +using Grpc.Core; +using KurrentDB.Protocol.V2.Streams.Errors; namespace KurrentDB.Client { /// /// Exception thrown when an append exceeds the maximum size set by the server. /// - public class MaximumAppendSizeExceededException : Exception { + public class AppendRecordSizeExceededException : Exception { /// - /// The configured maximum append size. + /// The name of the stream where the append was attempted. /// - public uint MaxAppendSize { get; } + public string Stream { get; } /// - /// Constructs a new . + /// The identifier of the offending and oversized record. /// - /// - /// - public MaximumAppendSizeExceededException(uint maxAppendSize, Exception? innerException = null) : - base($"Maximum Append Size of {maxAppendSize} Exceeded.", innerException) { - MaxAppendSize = maxAppendSize; - } + public string RecordId { get; } /// - /// Constructs a new . + /// The size of the huge record in bytes. /// - /// - /// - public MaximumAppendSizeExceededException(int maxAppendSize, Exception? innerException = null) : this( - (uint)maxAppendSize, innerException) { + public long Size { get; } + + /// + /// The maximum allowed size of a single record that can be appended in bytes. + /// + public long MaxSize { get; } + + /// + /// Constructs a new . + /// + /// The name of the stream where the append was attempted. + /// The identifier of the offending and oversized record. + /// The size of the huge record in bytes. + /// The maximum allowed size of a single record that can be appended in bytes. + /// The inner exception, if any. + public AppendRecordSizeExceededException(string stream, string recordId, long size, long maxSize, Exception? innerException = null) + : base($"The size of the record {recordId} ({size}) exceeds by maximum allowed size of {maxSize} bytes by {size - maxSize}", innerException) { + Stream = stream; + RecordId = recordId; + Size = size; + MaxSize = maxSize; + } + + public static AppendRecordSizeExceededException FromRpcException(RpcException ex) => FromRpcStatus(ex.GetRpcStatus()!); + public static AppendRecordSizeExceededException FromRpcStatus(Google.Rpc.Status ex) { + var details = ex.GetDetail(); + return new AppendRecordSizeExceededException( + details.Stream, + details.RecordId, + details.Size, + details.MaxSize + ); } } } diff --git a/src/KurrentDB.Client/Streams/MultiStreamAppend.Extensions.cs b/src/KurrentDB.Client/Streams/MultiStreamAppend.Extensions.cs new file mode 100644 index 000000000..e9c850abc --- /dev/null +++ b/src/KurrentDB.Client/Streams/MultiStreamAppend.Extensions.cs @@ -0,0 +1,37 @@ +namespace KurrentDB.Client; + +public static class MultiStreamAppendExtensions { + public static ValueTask MultiStreamAppendAsync( + this KurrentDBClient client, IEnumerable requests, CancellationToken cancellationToken = default + ) => + client.MultiStreamAppendAsync(requests.ToAsyncEnumerable(), cancellationToken); + + public static ValueTask MultiStreamAppendAsync( + this KurrentDBClient client, AppendStreamRequest request, CancellationToken cancellationToken = default + ) => + client.MultiStreamAppendAsync([request], cancellationToken); + + /// + /// Appends a series of messages to a specified stream while specifying the expected stream state. + /// + /// The name of the stream to which the messages will be appended. + /// The expected state of the stream to ensure consistency during the append operation. + /// A collection of messages to be appended to the stream. + /// A token to observe while waiting for the operation to complete, allowing for cancellation if needed. + public static ValueTask MultiStreamAppendAsync( + this KurrentDBClient client, string stream, StreamState expectedState, IEnumerable messages, + CancellationToken cancellationToken + ) => + client.MultiStreamAppendAsync(new AppendStreamRequest(stream, expectedState, messages), cancellationToken); + + public static ValueTask MultiStreamAppendAsync( + this KurrentDBClient client, string stream, StreamState expectedState, EventData message, + CancellationToken cancellationToken + ) => + client.MultiStreamAppendAsync(new AppendStreamRequest(stream, expectedState, [message]), cancellationToken); + + public static ValueTask MultiStreamAppendAsync( + this KurrentDBClient client, string stream, IEnumerable messages, CancellationToken cancellationToken + ) => + client.MultiStreamAppendAsync(new AppendStreamRequest(stream, StreamState.Any, messages), cancellationToken); +} diff --git a/src/KurrentDB.Client/Streams/Streams/AppendReq.cs b/src/KurrentDB.Client/Streams/Streams/AppendReq.cs index 32483d9d2..9a866b580 100644 --- a/src/KurrentDB.Client/Streams/Streams/AppendReq.cs +++ b/src/KurrentDB.Client/Streams/Streams/AppendReq.cs @@ -1,19 +1,20 @@ +using EventStore.Client; using KurrentDB.Client; -namespace EventStore.Client.Streams { - partial class AppendReq { - public AppendReq WithAnyStreamRevision(StreamState expectedState) { - if (expectedState == StreamState.Any) { - Options.Any = new Empty(); - } else if (expectedState == StreamState.NoStream) { - Options.NoStream = new Empty(); - } else if (expectedState == StreamState.StreamExists) { - Options.StreamExists = new Empty(); - } else { - Options.Revision = (ulong)expectedState.ToInt64(); - } +namespace KurrentDB.Protocol.Streams.V1; - return this; +partial class AppendReq { + public AppendReq WithAnyStreamRevision(StreamState expectedState) { + if (expectedState == StreamState.Any) { + Options.Any = new Empty(); + } else if (expectedState == StreamState.NoStream) { + Options.NoStream = new Empty(); + } else if (expectedState == StreamState.StreamExists) { + Options.StreamExists = new Empty(); + } else { + Options.Revision = (ulong)expectedState.ToInt64(); } + + return this; } } diff --git a/src/KurrentDB.Client/Streams/Streams/BatchAppendReq.cs b/src/KurrentDB.Client/Streams/Streams/BatchAppendReq.cs index 1eb679fb1..85233e4fa 100644 --- a/src/KurrentDB.Client/Streams/Streams/BatchAppendReq.cs +++ b/src/KurrentDB.Client/Streams/Streams/BatchAppendReq.cs @@ -1,7 +1,8 @@ +using EventStore.Client; using Google.Protobuf.WellKnownTypes; using KurrentDB.Client; -namespace EventStore.Client.Streams { +namespace KurrentDB.Protocol.Streams.V1 { partial class BatchAppendReq { partial class Types { partial class Options { diff --git a/src/KurrentDB.Client/Streams/Streams/BatchAppendResp.cs b/src/KurrentDB.Client/Streams/Streams/BatchAppendResp.cs index 38da31f17..44ec7984f 100644 --- a/src/KurrentDB.Client/Streams/Streams/BatchAppendResp.cs +++ b/src/KurrentDB.Client/Streams/Streams/BatchAppendResp.cs @@ -1,9 +1,12 @@ +using EventStore.Client; using Grpc.Core; using KurrentDB.Client; using static EventStore.Client.WrongExpectedVersion.CurrentStreamRevisionOptionOneofCase; using static EventStore.Client.WrongExpectedVersion.ExpectedStreamPositionOptionOneofCase; +using Position = KurrentDB.Client.Position; +using Timeout = EventStore.Client.Timeout; -namespace EventStore.Client.Streams { +namespace KurrentDB.Protocol.Streams.V1 { partial class BatchAppendResp { public IWriteResult ToWriteResult() => ResultCase switch { ResultOneofCase.Success => new SuccessResult( @@ -16,19 +19,19 @@ partial class BatchAppendResp { Success.Position.PreparePosition), _ => Position.End }), - ResultOneofCase.Error => Error.Details switch { - { } when Error.Details.Is(WrongExpectedVersion.Descriptor) => - FromWrongExpectedVersion(StreamIdentifier, Error.Details.Unpack()), - { } when Error.Details.Is(StreamDeleted.Descriptor) => + ResultOneofCase.Error => Error.Details.FirstOrDefault() switch { + { } detail when detail.Is(WrongExpectedVersion.Descriptor) => + FromWrongExpectedVersion(StreamIdentifier, detail.Unpack()), + { } detail when detail.Is(StreamDeleted.Descriptor) => throw new StreamDeletedException(StreamIdentifier!), - { } when Error.Details.Is(AccessDenied.Descriptor) => throw new AccessDeniedException(), - { } when Error.Details.Is(Timeout.Descriptor) => throw new RpcException( + { } detail when detail.Is(AccessDenied.Descriptor) => throw new AccessDeniedException(), + { } detail when detail.Is(Timeout.Descriptor) => throw new RpcException( new Status(StatusCode.DeadlineExceeded, Error.Message)), - { } when Error.Details.Is(Unknown.Descriptor) => throw new InvalidOperationException(Error.Message), - { } when Error.Details.Is(MaximumAppendSizeExceeded.Descriptor) => + { } detail when detail.Is(Unknown.Descriptor) => throw new InvalidOperationException(Error.Message), + { } detail when detail.Is(MaximumAppendSizeExceeded.Descriptor) => throw new MaximumAppendSizeExceededException( - Error.Details.Unpack().MaxAppendSize), - { } when Error.Details.Is(BadRequest.Descriptor) => throw new InvalidOperationException(Error.Details + detail.Unpack().MaxAppendSize), + { } detail when detail.Is(BadRequest.Descriptor) => throw new InvalidOperationException(detail .Unpack().Message), _ => throw new InvalidOperationException($"Could not recognize {Error.Message}") }, diff --git a/src/KurrentDB.Client/Streams/Streams/DeleteReq.cs b/src/KurrentDB.Client/Streams/Streams/DeleteReq.cs index 28651ca47..579cb6205 100644 --- a/src/KurrentDB.Client/Streams/Streams/DeleteReq.cs +++ b/src/KurrentDB.Client/Streams/Streams/DeleteReq.cs @@ -1,6 +1,7 @@ +using EventStore.Client; using KurrentDB.Client; -namespace EventStore.Client.Streams { +namespace KurrentDB.Protocol.Streams.V1 { partial class DeleteReq { public DeleteReq WithAnyStreamRevision(StreamState expectedState) { if (expectedState == StreamState.Any) { diff --git a/src/KurrentDB.Client/Streams/Streams/Model/SchemaDataFormat.cs b/src/KurrentDB.Client/Streams/Streams/Model/SchemaDataFormat.cs new file mode 100644 index 000000000..abf4ed31d --- /dev/null +++ b/src/KurrentDB.Client/Streams/Streams/Model/SchemaDataFormat.cs @@ -0,0 +1,19 @@ +namespace KurrentDB.Client; + +#pragma warning disable CS8509 +public enum SchemaDataFormat { + /// + /// The data format is not specified. + /// + Unspecified = 0, + + /// + /// The data is in JSON format. + /// + Json = 1, + + /// + /// The data is in raw bytes format. + /// + Bytes = 4 +} diff --git a/src/KurrentDB.Client/Streams/Streams/Model/StreamsClientMapper.cs b/src/KurrentDB.Client/Streams/Streams/Model/StreamsClientMapper.cs new file mode 100644 index 000000000..d8185f062 --- /dev/null +++ b/src/KurrentDB.Client/Streams/Streams/Model/StreamsClientMapper.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using Google.Protobuf; +using KurrentDB.Client.Diagnostics; +using KurrentDB.Protocol.V2.Streams; +using static KurrentDB.Client.Constants.Metadata; +using SchemaFormat = KurrentDB.Protocol.V2.Streams.SchemaFormat; + +namespace KurrentDB.Client; + +static class StreamsClientMapper { + public static async IAsyncEnumerable Map(this IEnumerable source) { + foreach (var message in source) + yield return await message + .Map() + .ConfigureAwait(false); + } + + public static ValueTask Map(this EventData source) { + Dictionary metadata; + + try { + metadata = source.Metadata.Decode() ?? new Dictionary(); + } catch (Exception ex) { + throw new ArgumentException( + "Failed to decode event metadata. The metadata may be missing, malformed, or not in valid JSON format. Please verify the event's metadata structure.", + ex + ); + } + + metadata.InjectTracingContext(Activity.Current); + + var record = new AppendRecord { + RecordId = source.EventId.ToString(), + Data = ByteString.CopyFrom(source.Data.Span), + Schema = new SchemaInfo { + Format = source.ContentType is ContentTypes.ApplicationJson + ? SchemaFormat.Json + : SchemaFormat.Bytes, + Name = source.Type + }, + Properties = { metadata.MapToMapValue() } + }; + + return new ValueTask(record); + } +} diff --git a/src/KurrentDB.Client/Streams/Streams/Model/StreamsClientModel.cs b/src/KurrentDB.Client/Streams/Streams/Model/StreamsClientModel.cs new file mode 100644 index 000000000..7ada568e5 --- /dev/null +++ b/src/KurrentDB.Client/Streams/Streams/Model/StreamsClientModel.cs @@ -0,0 +1,46 @@ +using JetBrains.Annotations; + +namespace KurrentDB.Client; + +/// +/// Represents a request to append events to a specific stream in KurrentDB. +/// +/// +/// The name of the stream to which the events are to be appended. +/// +/// +/// The expected state of the stream before performing the append operation +/// to enforce optimistic concurrency control by ensuring that the +/// stream's actual state matches the expected state before appending. +/// +/// +/// A collection of representing the events being appended +/// to the stream. Each event can contain a payload and an associated metadata. +/// +[PublicAPI] +public record AppendStreamRequest(string Stream, StreamState ExpectedState, IEnumerable Messages); + +/// +/// Represents the outcome of an append operation. +/// +/// +/// The name of the stream to which records were appended. +/// +/// +/// The expected revision of the stream after the append operation. +/// +[PublicAPI] +public record AppendResponse(string Stream, long StreamRevision); + +[PublicAPI] +public readonly struct MultiStreamAppendResponse(long position, IEnumerable? responses = null) { + /// + /// The position of the last appended record in the transaction. + /// + public readonly long Position = position; + + /// + /// The collection of responses for each stream appended in the transaction. + /// + public readonly IEnumerable? Responses = responses; +} diff --git a/src/KurrentDB.Client/Streams/Streams/ReadReq.cs b/src/KurrentDB.Client/Streams/Streams/ReadReq.cs index 1a8b513e4..b27130b00 100644 --- a/src/KurrentDB.Client/Streams/Streams/ReadReq.cs +++ b/src/KurrentDB.Client/Streams/Streams/ReadReq.cs @@ -1,7 +1,7 @@ -using System; +using EventStore.Client; using KurrentDB.Client; -namespace EventStore.Client.Streams { +namespace KurrentDB.Protocol.Streams.V1 { partial class ReadReq { partial class Types { partial class Options { diff --git a/src/KurrentDB.Client/Streams/Streams/TombstoneReq.cs b/src/KurrentDB.Client/Streams/Streams/TombstoneReq.cs index b86eb26d6..5273c38b4 100644 --- a/src/KurrentDB.Client/Streams/Streams/TombstoneReq.cs +++ b/src/KurrentDB.Client/Streams/Streams/TombstoneReq.cs @@ -1,6 +1,7 @@ +using EventStore.Client; using KurrentDB.Client; -namespace EventStore.Client.Streams { +namespace KurrentDB.Protocol.Streams.V1 { partial class TombstoneReq { public TombstoneReq WithAnyStreamRevision(StreamState expectedState) { if (expectedState == StreamState.Any) { diff --git a/src/KurrentDB.Client/UserManagement/KurrentDBUserManagementClient.cs b/src/KurrentDB.Client/UserManagement/KurrentDBUserManagementClient.cs index a7f801c99..d8a48dd06 100644 --- a/src/KurrentDB.Client/UserManagement/KurrentDBUserManagementClient.cs +++ b/src/KurrentDB.Client/UserManagement/KurrentDBUserManagementClient.cs @@ -1,8 +1,9 @@ using System.Runtime.CompilerServices; -using EventStore.Client.Users; using Grpc.Core; +using KurrentDB.Protocol.Users.V1; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using static KurrentDB.Protocol.Users.V1.Users; namespace KurrentDB.Client { /// @@ -46,7 +47,7 @@ public async Task CreateUserAsync(string loginName, string fullName, string[] gr if (password == string.Empty) throw new ArgumentOutOfRangeException(nameof(password)); var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Users.UsersClient( + using var call = new UsersClient( channelInfo.CallInvoker).CreateAsync(new CreateReq { Options = new CreateReq.Types.Options { LoginName = loginName, @@ -79,7 +80,7 @@ public async Task GetUserAsync(string loginName, TimeSpan? deadline } var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Users.UsersClient( + using var call = new UsersClient( channelInfo.CallInvoker).Details(new DetailsReq { Options = new DetailsReq.Types.Options { LoginName = loginName @@ -116,7 +117,7 @@ public async Task DeleteUserAsync(string loginName, TimeSpan? deadline = null, } var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - var call = new Users.UsersClient( + var call = new UsersClient( channelInfo.CallInvoker).DeleteAsync(new DeleteReq { Options = new DeleteReq.Types.Options { LoginName = loginName @@ -146,7 +147,7 @@ public async Task EnableUserAsync(string loginName, TimeSpan? deadline = null, } var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Users.UsersClient( + using var call = new UsersClient( channelInfo.CallInvoker).EnableAsync(new EnableReq { Options = new EnableReq.Types.Options { LoginName = loginName @@ -169,7 +170,7 @@ public async Task DisableUserAsync(string loginName, TimeSpan? deadline = null, if (loginName == string.Empty) throw new ArgumentOutOfRangeException(nameof(loginName)); var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - var call = new Users.UsersClient( + var call = new UsersClient( channelInfo.CallInvoker).DisableAsync(new DisableReq { Options = new DisableReq.Types.Options { LoginName = loginName @@ -189,7 +190,7 @@ public async IAsyncEnumerable ListAllAsync(TimeSpan? deadline = nul UserCredentials? userCredentials = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Users.UsersClient( + using var call = new UsersClient( channelInfo.CallInvoker).Details(new DetailsReq(), KurrentDBCallOptions.CreateNonStreaming(Settings, deadline, userCredentials, cancellationToken)); @@ -225,7 +226,7 @@ public async Task ChangePasswordAsync(string loginName, string currentPassword, if (newPassword == string.Empty) throw new ArgumentOutOfRangeException(nameof(newPassword)); var channelInfo = await GetChannelInfo(cancellationToken).ConfigureAwait(false); - using var call = new Users.UsersClient( + using var call = new UsersClient( channelInfo.CallInvoker).ChangePasswordAsync( new ChangePasswordReq { Options = new ChangePasswordReq.Types.Options { diff --git a/test/KurrentDB.Client.Tests.Common/Facts/MinimumVersion.cs b/test/KurrentDB.Client.Tests.Common/Facts/MinimumVersion.cs new file mode 100644 index 000000000..83ebcbebb --- /dev/null +++ b/test/KurrentDB.Client.Tests.Common/Facts/MinimumVersion.cs @@ -0,0 +1,112 @@ +// ReSharper disable InconsistentNaming + +using KurrentDB.Client.Tests.FluentDocker; + +namespace KurrentDB.Client.Tests; + +[PublicAPI] +public class MinimumVersion { + public class FactAttribute : Xunit.FactAttribute { + readonly int Major; + readonly int? Minor; + readonly int? Patch; + + public FactAttribute(int major) { + Major = major; + } + + public FactAttribute(int major, int minor) { + Major = major; + Minor = minor; + } + + public FactAttribute(int major, int minor, int patch) { + Major = major; + Minor = minor; + Patch = patch; + } + + public override string? Skip { + get { + var currentVersion = TestContainerService.Version; + var requiredVersionString = Patch.HasValue + ? $"{Major}.{Minor}.{Patch}" + : Minor.HasValue + ? $"{Major}.{Minor}" + : $"{Major}"; + + if (Patch.HasValue) { + var required = new Version(Major, Minor!.Value, Patch.Value); + return currentVersion < required + ? $"Test requires minimum version {requiredVersionString}, but current version is {currentVersion}" + : null; + } + + if (Minor.HasValue) { + if (currentVersion.Major < Major || + (currentVersion.Major == Major && currentVersion.Minor < Minor.Value)) { + return $"Test requires minimum version {requiredVersionString}, but current version is {currentVersion}"; + } + } else { + if (currentVersion.Major < Major) + return $"Test requires minimum major version {requiredVersionString}, but current version is {currentVersion}"; + } + + return null; + } + set => throw new NotSupportedException(); + } + } + + public class TheoryAttribute : Xunit.TheoryAttribute { + readonly int Major; + readonly int? Minor; + readonly int? Patch; + + public TheoryAttribute(int major) { + Major = major; + } + + public TheoryAttribute(int major, int minor) { + Major = major; + Minor = minor; + } + + public TheoryAttribute(int major, int minor, int patch) { + Major = major; + Minor = minor; + Patch = patch; + } + + public override string? Skip { + get { + var currentVersion = TestContainerService.Version; + var requiredVersionString = Patch.HasValue + ? $"{Major}.{Minor}.{Patch}" + : Minor.HasValue + ? $"{Major}.{Minor}" + : $"{Major}"; + + if (Patch.HasValue) { + var required = new Version(Major, Minor!.Value, Patch.Value); + return currentVersion < required + ? $"Test requires minimum version {requiredVersionString}, but current version is {currentVersion}" + : null; + } + + if (Minor.HasValue) { + if (currentVersion.Major < Major || + (currentVersion.Major == Major && currentVersion.Minor < Minor.Value)) { + return $"Test requires minimum version {requiredVersionString}, but current version is {currentVersion}"; + } + } else { + if (currentVersion.Major < Major) + return $"Test requires minimum major version {requiredVersionString}, but current version is {currentVersion}"; + } + + return null; + } + set => throw new NotSupportedException(); + } + } +} diff --git a/test/KurrentDB.Client.Tests.Common/Fixtures/BaseTestNode.cs b/test/KurrentDB.Client.Tests.Common/Fixtures/BaseTestNode.cs deleted file mode 100644 index df23f8bfa..000000000 --- a/test/KurrentDB.Client.Tests.Common/Fixtures/BaseTestNode.cs +++ /dev/null @@ -1,162 +0,0 @@ -// // ReSharper disable InconsistentNaming -// -// using System.Globalization; -// using System.Net; -// using System.Net.Sockets; -// using Ductus.FluentDocker.Builders; -// using Ductus.FluentDocker.Extensions; -// using Ductus.FluentDocker.Services.Extensions; -// using KurrentDB.Client.Tests.FluentDocker; -// using Humanizer; -// using Serilog; -// using Serilog.Extensions.Logging; -// using static System.TimeSpan; -// -// namespace KurrentDB.Client.Tests; -// -// public abstract class BaseTestNode(EventStoreFixtureOptions? options = null) : TestContainerService { -// static readonly NetworkPortProvider NetworkPortProvider = new(NetworkPortProvider.DefaultEsdbPort); -// -// public KurrentDBFixtureOptions Options { get; } = options ?? DefaultOptions(); -// -// static Version? _version; -// -// public static Version Version => _version ??= GetVersion(); -// -// public static EventStoreFixtureOptions DefaultOptions() { -// const string connString = "esdb://admin:changeit@localhost:{port}/?tlsVerifyCert=false"; -// -// var port = NetworkPortProvider.NextAvailablePort; -// -// var defaultSettings = EventStoreClientSettings -// .Create(connString.Replace("{port}", $"{port}")) -// .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)) -// .With(x => x.DefaultDeadline = Application.DebuggerIsAttached ? new TimeSpan?() : FromSeconds(30)) -// .With(x => x.ConnectivitySettings.MaxDiscoverAttempts = 20) -// .With(x => x.ConnectivitySettings.DiscoveryInterval = FromSeconds(1)); -// -// var defaultEnvironment = new Dictionary(GlobalEnvironment.Variables) { -// // ["EVENTSTORE_MEM_DB"] = "true", -// // ["EVENTSTORE_CERTIFICATE_FILE"] = "/etc/eventstore/certs/node/node.crt", -// // ["EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE"] = "/etc/eventstore/certs/node/node.key", -// // ["EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE"] = "10000", -// // ["EVENTSTORE_STREAM_INFO_CACHE_CAPACITY"] = "10000", -// // ["EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP"] = "true", -// // ["EVENTSTORE_LOG_LEVEL"] = "Default", // required to use serilog settings -// // ["EVENTSTORE_DISABLE_LOG_FILE"] = "true", -// // ["EVENTSTORE_START_STANDARD_PROJECTIONS"] = "true", -// // ["EVENTSTORE_RUN_PROJECTIONS"] = "All", -// // ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024 * 1024).ToString(), -// // ["EVENTSTORE_MAX_APPEND_SIZE"] = 100.Kilobytes().Bytes.ToString(CultureInfo.InvariantCulture), -// // ["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{NetworkPortProvider.DefaultEsdbPort}" -// -// ["EVENTSTORE_MEM_DB"] = "true", -// ["EVENTSTORE_CERTIFICATE_FILE"] = "/etc/eventstore/certs/node/node.crt", -// ["EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE"] = "/etc/eventstore/certs/node/node.key", -// ["EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE"] = "10000", -// ["EVENTSTORE_STREAM_INFO_CACHE_CAPACITY"] = "10000", -// ["EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP"] = "true", -// ["EVENTSTORE_LOG_LEVEL"] = "Default", // required to use serilog settings -// ["EVENTSTORE_DISABLE_LOG_FILE"] = "true", -// ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024 * 1024).ToString(), -// ["EVENTSTORE_MAX_APPEND_SIZE"] = 100.Kilobytes().Bytes.ToString(CultureInfo.InvariantCulture), -// ["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{NetworkPortProvider.DefaultEsdbPort}" -// }; -// -// if (port != NetworkPortProvider.DefaultEsdbPort) { -// if (GlobalEnvironment.Variables.TryGetValue("ES_DOCKER_TAG", out var tag) && tag == "ci") -// defaultEnvironment["EVENTSTORE_ADVERTISE_NODE_PORT_TO_CLIENT_AS"] = $"{port}"; -// else -// defaultEnvironment["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{port}"; -// } -// -// return new(defaultSettings, defaultEnvironment); -// } -// -// static Version GetVersion() { -// const string versionPrefix = "EventStoreDB version"; -// -// using var cts = new CancellationTokenSource(FromSeconds(30)); -// using var eventstore = new Builder().UseContainer() -// .UseImage(GlobalEnvironment.DockerImage) -// .Command("--version") -// .Build() -// .Start(); -// -// using var log = eventstore.Logs(true, cts.Token); -// foreach (var line in log.ReadToEnd()) { -// if (line.StartsWith(versionPrefix) && -// Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { -// return version; -// } -// } -// -// throw new InvalidOperationException("Could not determine server version."); -// -// IEnumerable ReadVersion(string s) { -// foreach (var c in s.TakeWhile(c => c == '.' || char.IsDigit(c))) { -// yield return c; -// } -// } -// } -// -// string[] GetEnvironmentVariables() => -// Options.Environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray(); -// -// protected abstract ContainerBuilder ConfigureContainer(ContainerBuilder builder); -// -// protected override ContainerBuilder Configure() { -// var certsPath = Path.Combine(Environment.CurrentDirectory, "certs"); -// -// CertificatesManager.VerifyCertificatesExist(certsPath); -// -// var builder = new Builder().UseContainer().WithEnvironment(GetEnvironmentVariables()); -// -// return ConfigureContainer(builder); -// } -// } -// -// /// -// /// Using the default 2113 port assumes that the test is running sequentially. -// /// -// /// -// class NetworkPortProvider(int port = 2114) { -// public const int DefaultEsdbPort = 2113; -// -// static readonly SemaphoreSlim Semaphore = new(1, 1); -// -// async Task GetNextAvailablePort(TimeSpan delay = default) { -// if (port == DefaultEsdbPort) -// return port; -// -// await Semaphore.WaitAsync(); -// -// try { -// using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); -// -// while (true) { -// var nexPort = Interlocked.Increment(ref port); -// -// try { -// await socket.ConnectAsync(IPAddress.Any, nexPort); -// } catch (SocketException ex) { -// if (ex.SocketErrorCode is SocketError.ConnectionRefused or not SocketError.IsConnected) { -// return nexPort; -// } -// -// await Task.Delay(delay); -// } finally { -// #if NET -// if (socket.Connected) await socket.DisconnectAsync(true); -// #else -// if (socket.Connected) socket.Disconnect(true); -// #endif -// } -// } -// } finally { -// Semaphore.Release(); -// } -// } -// -// public int NextAvailablePort => GetNextAvailablePort(FromMilliseconds(100)).GetAwaiter().GetResult(); -// } diff --git a/test/KurrentDB.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs b/test/KurrentDB.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs new file mode 100644 index 000000000..1ef5c9a7a --- /dev/null +++ b/test/KurrentDB.Client.Tests.Common/Fixtures/DiagnosticsFixture.cs @@ -0,0 +1,123 @@ +// ReSharper disable InconsistentNaming + +using System.Collections.Concurrent; +using System.Diagnostics; +using KurrentDB.Client.Diagnostics; +using KurrentDB.Client.Tests.TestNode; +using KurrentDB.Diagnostics; +using KurrentDB.Diagnostics.Telemetry; +using KurrentDB.Diagnostics.Tracing; + +namespace KurrentDB.Client.Tests.Fixtures; + +public class DiagnosticsFixture : KurrentDBPermanentFixture { + readonly ConcurrentDictionary<(string Operation, ActivityTraceId TraceId), List> Activities = []; + + public DiagnosticsFixture() : base(x => x.RunProjections()) { + var diagnosticActivityListener = new ActivityListener { + ShouldListenTo = source => source.Name == KurrentDBClientDiagnostics.InstrumentationName, + Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => { + var operation = (string?)activity.GetTagItem(TelemetryTags.Database.Operation); + + if (operation is null) + return; + + Activities.AddOrUpdate( + (operation, activity.TraceId), + _ => [activity], + (_, activities) => { + activities.Add(activity); + return activities; + } + ); + } + }; + + OnSetup += () => { + ActivitySource.AddActivityListener(diagnosticActivityListener); + return Task.CompletedTask; + }; + + OnTearDown = () => { + diagnosticActivityListener.Dispose(); + return Task.CompletedTask; + }; + } + + public ActivityTraceId CreateTraceId() { + var activity = new Activity(Guid.NewGuid().ToString("N")); + activity.Start(); + Activity.Current = activity; + return activity.TraceId; + } + + public List GetActivities(string operation, ActivityTraceId traceId) => + Activities.TryGetValue((operation, traceId), out var activities) ? activities : []; + + public void AssertMultiAppendActivityHasExpectedTags(Activity activity) { + var expectedTags = new Dictionary { + { TelemetryTags.Database.System, KurrentDBClientDiagnostics.InstrumentationName }, + { TelemetryTags.Database.Operation, TracingConstants.Operations.MultiAppend }, + { TelemetryTags.Database.User, TestCredentials.Root.Username }, + { TelemetryTags.Otel.StatusCode, ActivityStatusCodeHelper.OkStatusCodeTagValue } + }; + + foreach (var tag in expectedTags) + activity.Tags.ShouldContain(tag); + } + + public void AssertAppendActivityHasExpectedTags(Activity activity, string stream) { + var expectedTags = new Dictionary { + { TelemetryTags.Database.System, KurrentDBClientDiagnostics.InstrumentationName }, + { TelemetryTags.Database.Operation, TracingConstants.Operations.Append }, + { TelemetryTags.KurrentDB.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 = null + ) { + var expectedTags = new Dictionary { + { TelemetryTags.Database.System, KurrentDBClientDiagnostics.InstrumentationName }, + { TelemetryTags.Database.Operation, TracingConstants.Operations.Subscribe }, + { TelemetryTags.KurrentDB.Stream, stream }, + { TelemetryTags.KurrentDB.EventId, eventId }, + { TelemetryTags.KurrentDB.EventType, TestEventType }, + { TelemetryTags.Database.User, TestCredentials.Root.Username } + }; + + if (subscriptionId != null) + expectedTags[TelemetryTags.KurrentDB.SubscriptionId] = subscriptionId; + + foreach (var tag in expectedTags) { + activity.Tags.ShouldContain(tag); + } + } +} diff --git a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentFixture.cs b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentFixture.cs index 7dced2d4e..a1bb17b55 100644 --- a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentFixture.cs +++ b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentFixture.cs @@ -1,10 +1,6 @@ // ReSharper disable InconsistentNaming -using System.Net; -using Ductus.FluentDocker.Builders; -using Ductus.FluentDocker.Extensions; -using Ductus.FluentDocker.Services.Extensions; -using KurrentDB.Client; +using System.Net.Http; using KurrentDB.Client.Tests.FluentDocker; using Serilog; using static System.TimeSpan; @@ -13,18 +9,15 @@ namespace KurrentDB.Client.Tests; [PublicAPI] public partial class KurrentDBPermanentFixture : IAsyncLifetime, IAsyncDisposable { - static readonly ILogger Logger; + static readonly ILogger Logger; + static readonly SemaphoreSlim WarmUpGatekeeper = new(1, 1); static KurrentDBPermanentFixture() { Logging.Initialize(); Logger = Serilog.Log.ForContext(); -#if NET9_0_OR_GREATER var httpClientHandler = new HttpClientHandler(); httpClientHandler.ServerCertificateCustomValidationCallback = delegate { return true; }; -#else - ServicePointManager.ServerCertificateValidationCallback = delegate { return true; }; -#endif } public KurrentDBPermanentFixture() : this(options => options) { } @@ -38,21 +31,18 @@ protected KurrentDBPermanentFixture(ConfigureFixture configure) { public ILogger Log => Logger; - public ITestService Service { get; } - public KurrentDBFixtureOptions Options { get; } - public Faker Faker { get; } = new Faker(); - - public Version EventStoreVersion { get; private set; } = null!; - - public bool IsKdb => EventStoreVersion.Major >= 25; + public KurrentDBPermanentTestNode Service { get; } + public KurrentDBFixtureOptions Options { get; } + public Faker Faker { get; } = new(); - public bool EventStoreHasLastStreamPosition { get; private set; } + public Version DatabaseVersion { get; private set; } = null!; + public bool HasLastStreamPosition { get; private set; } public KurrentDBClient Streams { get; private set; } = null!; - public KurrentDBUserManagementClient DBUsers { get; private set; } = null!; - public KurrentDBProjectionManagementClient DBProjections { get; private set; } = null!; + public KurrentDBUserManagementClient DBUsers { get; private set; } = null!; + public KurrentDBProjectionManagementClient DBProjections { get; private set; } = null!; public KurrentDBPersistentSubscriptionsClient Subscriptions { get; private set; } = null!; - public KurrentDBOperationsClient DBOperations { get; private set; } = null!; + public KurrentDBOperationsClient DBOperations { get; private set; } = null!; public bool SkipPsWarmUp { get; set; } @@ -75,36 +65,30 @@ protected KurrentDBPermanentFixture(ConfigureFixture configure) { DefaultDeadline = Options.DBClientSettings.DefaultDeadline }; - InterlockedBoolean WarmUpCompleted { get; } = new InterlockedBoolean(); - static readonly SemaphoreSlim WarmUpGatekeeper = new(1, 1); + InterlockedBoolean WarmUpCompleted { get; } = new(); - public void CaptureTestRun(ITestOutputHelper outputHelper) { - var testRunId = Logging.CaptureLogs(outputHelper); - TestRuns.Add(testRunId); - Logger.Information(">>> Test Run {TestRunId} {Operation} <<<", testRunId, "starting"); - Service.ReportStatus(); - } + async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync(); public async Task InitializeAsync() { await WarmUpGatekeeper.WaitAsync(); try { await Service.Start(); - EventStoreVersion = GetKurrentVersion(); - EventStoreHasLastStreamPosition = (EventStoreVersion?.Major ?? int.MaxValue) >= 21; + DatabaseVersion = TestContainerService.Version; + HasLastStreamPosition = (DatabaseVersion?.Major ?? int.MaxValue) >= 21; if (!WarmUpCompleted.CurrentValue) { Logger.Warning("*** Warmup started ***"); await Task.WhenAll( InitClient(async x => DBUsers = await Task.FromResult(x)), - InitClient(async x => Streams = await Task.FromResult(x)), + InitClient(async x => Streams = await Task.FromResult(x)), InitClient( async x => DBProjections = await Task.FromResult(x), Options.Environment["EVENTSTORE_RUN_PROJECTIONS"] != "None" ), InitClient(async x => Subscriptions = SkipPsWarmUp ? x : await Task.FromResult(x)), - InitClient(async x => DBOperations = await Task.FromResult(x)) + InitClient(async x => DBOperations = await Task.FromResult(x)) ); WarmUpCompleted.EnsureCalledOnce(); @@ -128,42 +112,6 @@ async Task InitClient(Func action, bool execute = true) where T : await action(client); return client; } - - static Version GetKurrentVersion() { - const string versionPrefix = "KurrentDB version"; - const string esdbVersionPrefix = "EventStoreDB version"; - - using var cancellator = new CancellationTokenSource(FromSeconds(30)); - using var eventstore = new Builder() - .UseContainer() - .UseImage(GlobalEnvironment.DockerImage) - .Command("--version") - .Build() - .Start(); - - using var log = eventstore.Logs(true, cancellator.Token); - var logs = log.ReadToEnd(); - foreach (var line in logs) { - Logger.Information("KurrentDB: {Line}", line); - if (line.StartsWith(versionPrefix) && - Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { - return version; - } - - if (line.StartsWith(esdbVersionPrefix) && - Version.TryParse(new string(ReadVersion(line[(esdbVersionPrefix.Length + 1)..]).ToArray()), out var esdbVersion)) { - return esdbVersion; - } - } - - throw new InvalidOperationException($"Could not determine server version from logs: {string.Join(Environment.NewLine, logs)}"); - - IEnumerable ReadVersion(string s) { - foreach (var c in s.TakeWhile(c => c == '.' || char.IsDigit(c))) { - yield return c; - } - } - } } public async Task DisposeAsync() { @@ -179,7 +127,12 @@ public async Task DisposeAsync() { Logging.ReleaseLogs(testRunId); } - async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync(); + public void CaptureTestRun(ITestOutputHelper outputHelper) { + var testRunId = Logging.CaptureLogs(outputHelper); + TestRuns.Add(testRunId); + Logger.Information(">>> Test Run {TestRunId} {Operation} <<<", testRunId, "starting"); + Service.ReportStatus(); + } } public abstract class KurrentDBPermanentTests : IClassFixture where TFixture : KurrentDBPermanentFixture { diff --git a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentTestNode.cs b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentTestNode.cs index 73be35ebe..52b4d577c 100644 --- a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentTestNode.cs +++ b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBPermanentTestNode.cs @@ -1,214 +1,41 @@ // ReSharper disable InconsistentNaming +// ReSharper disable InvertIf -// using Ductus.FluentDocker.Builders; -// using Ductus.FluentDocker.Model.Builders; -// using KurrentDB.Client.Tests.FluentDocker; -// -// namespace KurrentDB.Client.Tests; -// -// public class EventStorePermanentTestNode(EventStoreFixtureOptions? options = null) : BaseTestNode(options) { -// protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder) { -// var port = Options.ClientSettings.ConnectivitySettings.ResolvedAddressOrDefault.Port; -// var certsPath = Path.Combine(Environment.CurrentDirectory, "certs"); -// -// var containerName = "es-client-dotnet-test"; -// -// return builder -// .UseImage(Options.Environment["ES_DOCKER_IMAGE"]) -// .WithName(containerName) -// .WithPublicEndpointResolver() -// .MountVolume(certsPath, "/etc/eventstore/certs", MountType.ReadOnly) -// .ExposePort(port, 2113) -// .KeepContainer().KeepRunning().ReuseIfExists() -// .WaitUntilReadyWithConstantBackoff( -// 1_000, -// 60, -// service => { -// var output = service.ExecuteCommand("curl -u admin:changeit --cacert /etc/eventstore/certs/ca/ca.crt https://localhost:2113/health/live"); -// if (!output.Success) -// throw new Exception(output.Error); -// } -// ); -// } -// } - -using System.Globalization; -using System.Net; -using System.Net.Sockets; using Ductus.FluentDocker.Builders; -using Ductus.FluentDocker.Extensions; -using Ductus.FluentDocker.Model.Builders; -using Ductus.FluentDocker.Services.Extensions; -using KurrentDB.Client; using KurrentDB.Client.Tests.FluentDocker; -using Humanizer; -using KurrentDB.Client.Tests; using Serilog; using Serilog.Extensions.Logging; -using static System.TimeSpan; -public class KurrentDBPermanentTestNode(KurrentDBFixtureOptions? options = null) : TestContainerService { - static readonly NetworkPortProvider NetworkPortProvider = new(NetworkPortProvider.DefaultEsdbPort); +namespace KurrentDB.Client.Tests; +public class KurrentDBPermanentTestNode(KurrentDBFixtureOptions? options = null) : TestContainerService { KurrentDBFixtureOptions Options { get; } = options ?? DefaultOptions(); - static Version? _version; - - public static Version Version => _version ??= GetVersion(); - public static KurrentDBFixtureOptions DefaultOptions() { - const string connString = "esdb://admin:changeit@localhost:{port}/?tlsVerifyCert=false"; - - var port = NetworkPortProvider.NextAvailablePort; + const int port = 2113; var defaultSettings = KurrentDBClientSettings - .Create(connString.Replace("{port}", $"{port}")) - .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)) - .With(x => x.DefaultDeadline = Application.DebuggerIsAttached ? new TimeSpan?() : FromSeconds(30)) - .With(x => x.ConnectivitySettings.MaxDiscoverAttempts = 20) - .With(x => x.ConnectivitySettings.DiscoveryInterval = FromSeconds(1)); + .Create(ConnectionString.Replace("{port}", port.ToString())) + .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)); var defaultEnvironment = new Dictionary(GlobalEnvironment.Variables) { - ["EVENTSTORE_MEM_DB"] = "true", - ["EVENTSTORE_CERTIFICATE_FILE"] = "/etc/eventstore/certs/node/node.crt", - ["EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE"] = "/etc/eventstore/certs/node/node.key", - ["EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE"] = "10000", - ["EVENTSTORE_STREAM_INFO_CACHE_CAPACITY"] = "10000", - ["EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP"] = "true", - ["EVENTSTORE_LOG_LEVEL"] = "Default", // required to use serilog settings - ["EVENTSTORE_DISABLE_LOG_FILE"] = "true", ["EVENTSTORE_START_STANDARD_PROJECTIONS"] = "true", ["EVENTSTORE_RUN_PROJECTIONS"] = "All", - ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024 * 1024).ToString(), - ["EVENTSTORE_MAX_APPEND_SIZE"] = 100.Kilobytes().Bytes.ToString(CultureInfo.InvariantCulture), - ["EVENTSTORE_MAX_APPEND_EVENT_SIZE"] = 100.Kilobytes().Bytes.ToString(CultureInfo.InvariantCulture), - ["EVENTSTORE_ADVERTISE_NODE_PORT_TO_CLIENT_AS"] = $"{NetworkPortProvider.DefaultEsdbPort}" + ["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = port.ToString(), + ["EVENTSTORE_ADVERTISE_NODE_PORT_TO_CLIENT_AS"] = port.ToString(), + ["EVENTSTORE_NODE_PORT"] = port.ToString(), }; - if (GlobalEnvironment.DockerImage.Contains("commercial")) { - defaultEnvironment["EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH"] = "/etc/eventstore/certs/ca"; - defaultEnvironment["EventStore__Plugins__UserCertificates__Enabled"] = "true"; - } - - if (port != NetworkPortProvider.DefaultEsdbPort) { - if (GlobalEnvironment.Variables.TryGetValue("ES_DOCKER_TAG", out var tag) && tag == "ci") - defaultEnvironment["EVENTSTORE_ADVERTISE_NODE_PORT_TO_CLIENT_AS"] = $"{port}"; - else - defaultEnvironment["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{port}"; - } - return new(defaultSettings, defaultEnvironment); } - static Version GetVersion() { - const string versionPrefix = "KurrentDB version"; - const string esdbVersionPrefix = "EventStoreDB version"; - - using var cts = new CancellationTokenSource(FromSeconds(30)); - using var eventstore = new Builder().UseContainer() - .UseImage(GlobalEnvironment.DockerImage) - .Command("--version") - .Build() - .Start(); - - using var log = eventstore.Logs(true, cts.Token); - var logs = log.ReadToEnd(); - foreach (var line in logs) { - if (line.StartsWith(versionPrefix) && - Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { - return version; - } - - if (line.StartsWith(esdbVersionPrefix) && - Version.TryParse(new string(ReadVersion(line[(esdbVersionPrefix.Length + 1)..]).ToArray()), out var esdbVersion)) { - return esdbVersion; - } - } - - throw new InvalidOperationException($"Could not determine server version from logs: {string.Join(Environment.NewLine, logs)}"); - - IEnumerable ReadVersion(string s) { - foreach (var c in s.TakeWhile(c => c == '.' || char.IsDigit(c))) { - yield return c; - } - } - } - protected override ContainerBuilder Configure() { - var env = Options.Environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray(); + var builder = CreateContainer(Options.Environment); - var port = Options.DBClientSettings.ConnectivitySettings.ResolvedAddressOrDefault.Port; - var certsPath = Path.Combine(Environment.CurrentDirectory, "certs"); + builder.KeepContainer() + .KeepRunning() + .ReuseIfExists(); - var containerName = port == 2113 - ? "es-client-dotnet-test" - : $"es-client-dotnet-test-{port}-{Guid.NewGuid().ToString()[30..]}"; - - CertificatesManager.VerifyCertificatesExist(certsPath); - - return new Builder() - .UseContainer() - .UseImage(Options.Environment["ES_DOCKER_IMAGE"]) - .WithName(containerName) - .WithPublicEndpointResolver() - .WithEnvironment(env) - .MountVolume(certsPath, "/etc/eventstore/certs", MountType.ReadOnly) - .ExposePort(port, 2113) - .KeepContainer().KeepRunning().ReuseIfExists() - .WaitUntilReadyWithConstantBackoff( - 1_000, - 60, - service => { - var output = service.ExecuteCommand("curl -u admin:changeit --cacert /etc/eventstore/certs/ca/ca.crt https://localhost:2113/health/live"); - if (!output.Success) - throw new Exception(output.Error); - } - ); + return AddReadinessCheck(builder); } } - -/// -/// Using the default 2113 port assumes that the test is running sequentially. -/// -/// -class NetworkPortProvider(int port = 2114) { - public const int DefaultEsdbPort = 2113; - - static readonly SemaphoreSlim Semaphore = new(1, 1); - - async Task GetNextAvailablePort(TimeSpan delay = default) { - // TODO SS: find a way to enable parallel tests on CI - if (port == DefaultEsdbPort) - return port; - - await Semaphore.WaitAsync(); - - try { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - - while (true) { - var nexPort = Interlocked.Increment(ref port); - - try { - await socket.ConnectAsync(IPAddress.Any, nexPort); - } catch (SocketException ex) { - if (ex.SocketErrorCode is SocketError.ConnectionRefused or not SocketError.IsConnected) { - return nexPort; - } - - await Task.Delay(delay); - } finally { -#if NET - if (socket.Connected) await socket.DisconnectAsync(true); -#else - if (socket.Connected) socket.Disconnect(true); -#endif - } - } - } finally { - Semaphore.Release(); - } - } - - public int NextAvailablePort => GetNextAvailablePort(FromMilliseconds(100)).GetAwaiter().GetResult(); -} diff --git a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryFixture.cs b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryFixture.cs index fc36b2f9e..76b57ffee 100644 --- a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryFixture.cs +++ b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryFixture.cs @@ -1,11 +1,9 @@ // ReSharper disable InconsistentNaming -using System.Net; -using Ductus.FluentDocker.Builders; -using Ductus.FluentDocker.Extensions; -using Ductus.FluentDocker.Services.Extensions; +using System.Net.Http; using KurrentDB.Client.Tests.FluentDocker; using Serilog; +using Serilog.Extensions.Logging; using static System.TimeSpan; namespace KurrentDB.Client.Tests.TestNode; @@ -18,38 +16,35 @@ static KurrentDBTemporaryFixture() { Logging.Initialize(); Logger = Serilog.Log.ForContext(); -#if NET9_0_OR_GREATER var httpClientHandler = new HttpClientHandler(); httpClientHandler.ServerCertificateCustomValidationCallback = delegate { return true; }; -#else - ServicePointManager.ServerCertificateValidationCallback = delegate { return true; }; -#endif } public KurrentDBTemporaryFixture() : this(options => options) { } protected KurrentDBTemporaryFixture(ConfigureFixture configure) { - // Options = configure(EventStoreTemporaryTestNode.DefaultOptions()); Options = configure(KurrentDBTemporaryTestNode.DefaultOptions()); Service = new KurrentDBTemporaryTestNode(Options); + + Options.DBClientSettings.LoggerFactory = new SerilogLoggerFactory(Logger); } List TestRuns { get; } = new(); public ILogger Log => Logger; - public ITestService Service { get; } + public ITestService Service { get; } public KurrentDBFixtureOptions Options { get; } - public Faker Faker { get; } = new Faker(); + public Faker Faker { get; } = new Faker(); - public Version EventStoreVersion { get; private set; } = null!; - public bool EventStoreHasLastStreamPosition { get; private set; } + public Version DatabaseVersion { get; private set; } = null!; + public bool HasLastStreamPosition { get; private set; } public KurrentDBClient Streams { get; private set; } = null!; - public KurrentDBUserManagementClient DBUsers { get; private set; } = null!; - public KurrentDBProjectionManagementClient DBProjections { get; private set; } = null!; + public KurrentDBUserManagementClient DBUsers { get; private set; } = null!; + public KurrentDBProjectionManagementClient DBProjections { get; private set; } = null!; public KurrentDBPersistentSubscriptionsClient Subscriptions { get; private set; } = null!; - public KurrentDBOperationsClient DBOperations { get; private set; } = null!; + public KurrentDBOperationsClient DBOperations { get; private set; } = null!; public bool SkipPsWarmUp { get; set; } @@ -87,8 +82,8 @@ public async Task InitializeAsync() { try { await Service.Start(); - EventStoreVersion = GetKurrentVersion(); - EventStoreHasLastStreamPosition = (EventStoreVersion?.Major ?? int.MaxValue) >= 21; + DatabaseVersion = TestContainerService.Version; + HasLastStreamPosition = (DatabaseVersion?.Major ?? int.MaxValue) >= 21; if (!WarmUpCompleted.CurrentValue) { Logger.Warning("*** Warmup started ***"); @@ -125,42 +120,6 @@ async Task InitClient(Func action, bool execute = true) where T : await action(client); return client; } - - static Version GetKurrentVersion() { - const string versionPrefix = "KurrentDB version"; - const string esdbVersionPrefix = "EventStoreDB version"; - - using var cancellator = new CancellationTokenSource(FromSeconds(30)); - using var eventstore = new Builder() - .UseContainer() - .UseImage(GlobalEnvironment.DockerImage) - .Command("--version") - .Build() - .Start(); - - using var log = eventstore.Logs(true, cancellator.Token); - var logs = log.ReadToEnd(); - foreach (var line in logs) { - Logger.Information("KurrentDB: {Line}", line); - if (line.StartsWith(versionPrefix) && - Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { - return version; - } - - if (line.StartsWith(esdbVersionPrefix) && - Version.TryParse(new string(ReadVersion(line[(esdbVersionPrefix.Length + 1)..]).ToArray()), out var esdbVersion)) { - return esdbVersion; - } - } - - throw new InvalidOperationException($"Could not determine server version from logs: {string.Join(Environment.NewLine, logs)}"); - - IEnumerable ReadVersion(string s) { - foreach (var c in s.TakeWhile(c => c == '.' || char.IsDigit(c))) { - yield return c; - } - } - } } public async Task DisposeAsync() { diff --git a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryTestNode.cs b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryTestNode.cs index e067a4a75..1d9eee2c6 100644 --- a/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryTestNode.cs +++ b/test/KurrentDB.Client.Tests.Common/Fixtures/KurrentDBTemporaryTestNode.cs @@ -1,166 +1,44 @@ // ReSharper disable InconsistentNaming +// ReSharper disable InvertIf -// using Ductus.FluentDocker.Builders; -// using Ductus.FluentDocker.Model.Builders; -// using KurrentDB.Client.Tests.FluentDocker; -// -// namespace KurrentDB.Client.Tests.TestNode; -// -// public class EventStoreTemporaryTestNode(EventStoreFixtureOptions? options = null) : BaseTestNode(options) { -// protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder) { -// var port = Options.ClientSettings.ConnectivitySettings.ResolvedAddressOrDefault.Port; -// var certsPath = Path.Combine(Environment.CurrentDirectory, "certs"); -// -// var containerName = $"es-client-dotnet-test-{port}-{Guid.NewGuid().ToString()[30..]}"; -// -// return builder -// .UseImage(Options.Environment["ES_DOCKER_IMAGE"]) -// .WithName(containerName) -// .WithPublicEndpointResolver() -// .MountVolume(certsPath, "/etc/eventstore/certs", MountType.ReadOnly) -// .ExposePort(port, 2113) -// .WaitUntilReadyWithConstantBackoff( -// 1_000, -// 60, -// service => { -// var output = service.ExecuteCommand("curl -u admin:changeit --cacert /etc/eventstore/certs/ca/ca.crt https://localhost:2113/health/live"); -// if (!output.Success) -// throw new Exception(output.Error); -// } -// ); -// } -// } - -using System.Globalization; using System.Net; using System.Net.Sockets; using Ductus.FluentDocker.Builders; -using Ductus.FluentDocker.Extensions; -using Ductus.FluentDocker.Model.Builders; -using Ductus.FluentDocker.Services.Extensions; -using KurrentDB.Client; -using KurrentDB.Client.Tests.FluentDocker; using Humanizer; +using KurrentDB.Client.Tests.FluentDocker; using Serilog; using Serilog.Extensions.Logging; -using static System.TimeSpan; -namespace KurrentDB.Client.Tests.TestNode; +namespace KurrentDB.Client.Tests; public class KurrentDBTemporaryTestNode(KurrentDBFixtureOptions? options = null) : TestContainerService { - static readonly NetworkPortProvider NetworkPortProvider = new(NetworkPortProvider.DefaultEsdbPort); - KurrentDBFixtureOptions Options { get; } = options ?? DefaultOptions(); - static Version? _version; - - public static Version Version => _version ??= GetVersion(); + static readonly NetworkPortProvider PortProvider = new(NetworkPortProvider.DefaultPort); public static KurrentDBFixtureOptions DefaultOptions() { - const string connString = "esdb://admin:changeit@localhost:{port}/?tlsVerifyCert=false"; - - var port = NetworkPortProvider.NextAvailablePort; + var port = PortProvider.NextAvailablePort; var defaultSettings = KurrentDBClientSettings - .Create(connString.Replace("{port}", $"{port}")) - .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)) - .With(x => x.DefaultDeadline = Application.DebuggerIsAttached ? new TimeSpan?() : FromSeconds(30)) - .With(x => x.ConnectivitySettings.MaxDiscoverAttempts = 20) - .With(x => x.ConnectivitySettings.DiscoveryInterval = FromSeconds(1)); + .Create(ConnectionString.Replace("{port}", $"{port}")) + .With(x => x.LoggerFactory = new SerilogLoggerFactory(Log.Logger)); var defaultEnvironment = new Dictionary(GlobalEnvironment.Variables) { - ["EVENTSTORE_MEM_DB"] = "true", - ["EVENTSTORE_CERTIFICATE_FILE"] = "/etc/eventstore/certs/node/node.crt", - ["EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE"] = "/etc/eventstore/certs/node/node.key", - ["EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE"] = "10000", - ["EVENTSTORE_STREAM_INFO_CACHE_CAPACITY"] = "10000", - ["EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP"] = "true", - ["EVENTSTORE_LOG_LEVEL"] = "Default", // required to use serilog settings - ["EVENTSTORE_DISABLE_LOG_FILE"] = "true", - ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024 * 1024).ToString(), - ["EVENTSTORE_MAX_APPEND_SIZE"] = 100.Kilobytes().Bytes.ToString(CultureInfo.InvariantCulture), - ["EVENTSTORE_MAX_APPEND_EVENT_SIZE"] = 100.Kilobytes().Bytes.ToString(CultureInfo.InvariantCulture), - ["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{NetworkPortProvider.DefaultEsdbPort}" + ["EVENTSTORE_ADVERTISE_NODE_PORT_TO_CLIENT_AS"] = $"{port}", + ["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{port}", }; - if (GlobalEnvironment.DockerImage.Contains("commercial")) { - defaultEnvironment["EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH"] = "/etc/eventstore/certs/ca"; - defaultEnvironment["EventStore__Plugins__UserCertificates__Enabled"] = "true"; - } - - if (port != NetworkPortProvider.DefaultEsdbPort) { - if (GlobalEnvironment.Variables.TryGetValue("ES_DOCKER_TAG", out var tag) && tag == "ci") - defaultEnvironment["EVENTSTORE_ADVERTISE_NODE_PORT_TO_CLIENT_AS"] = $"{port}"; - else - defaultEnvironment["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{port}"; - } - return new(defaultSettings, defaultEnvironment); } - static Version GetVersion() { - const string versionPrefix = "KurrentDB version"; - const string esdbVersionPrefix = "EventStoreDB version"; - - using var cts = new CancellationTokenSource(FromSeconds(30)); - using var eventstore = new Builder().UseContainer() - .UseImage(GlobalEnvironment.DockerImage) - .Command("--version") - .Build() - .Start(); - - using var log = eventstore.Logs(true, cts.Token); - var logs = log.ReadToEnd(); - foreach (var line in logs) { - if (line.StartsWith(versionPrefix) && - Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { - return version; - } - - if (line.StartsWith(esdbVersionPrefix) && - Version.TryParse(new string(ReadVersion(line[(esdbVersionPrefix.Length + 1)..]).ToArray()), out var esdbVersion)) { - return esdbVersion; - } - } - - throw new InvalidOperationException($"Could not determine server version from logs: {string.Join(Environment.NewLine, logs)}"); + protected override ContainerBuilder Configure() { + var port = Options.DBClientSettings.ConnectivitySettings.ResolvedAddressOrDefault.Port; - IEnumerable ReadVersion(string s) { - foreach (var c in s.TakeWhile(c => c == '.' || char.IsDigit(c))) { - yield return c; - } - } - } + var containerName = $"dotnet-client-test-{port}-{Guid.NewGuid():N}"; - protected override ContainerBuilder Configure() { - var env = Options.Environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray(); - - var port = Options.DBClientSettings.ConnectivitySettings.ResolvedAddressOrDefault.Port; - var certsPath = Path.Combine(Environment.CurrentDirectory, "certs"); - - var containerName = $"es-client-dotnet-test-{port}-{Guid.NewGuid().ToString()[30..]}"; - - CertificatesManager.VerifyCertificatesExist(certsPath); - - var builder = new Builder() - .UseContainer() - .UseImage(Options.Environment["ES_DOCKER_IMAGE"]) - .WithName(containerName) - .WithPublicEndpointResolver() - .WithEnvironment(env) - .MountVolume(certsPath, "/etc/eventstore/certs", MountType.ReadOnly) - .ExposePort(port, 2113) - .WaitUntilReadyWithConstantBackoff( - 1_000, - 60, - service => { - var output = service.ExecuteCommand("curl -u admin:changeit --cacert /etc/eventstore/certs/ca/ca.crt https://localhost:2113/health/live"); - if (!output.Success) - throw new Exception(output.Error); - } - ); + var builder = CreateContainer(Options.Environment, containerName, port); - return builder; + return AddReadinessCheck(builder); } } @@ -168,8 +46,10 @@ protected override ContainerBuilder Configure() { /// Using the default 2113 port assumes that the test is running sequentially. /// /// -class NetworkPortProvider(int port = 2114) { - public const int DefaultEsdbPort = 2113; +internal class NetworkPortProvider(int port = 2114) { + int _port = port; + + public const int DefaultPort = 2113; static readonly SemaphoreSlim Semaphore = new(1, 1); @@ -180,7 +60,7 @@ async Task GetNextAvailablePort(TimeSpan delay = default) { using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); while (true) { - var nexPort = Interlocked.Increment(ref port); + var nexPort = Interlocked.Increment(ref _port); try { await socket.ConnectAsync(IPAddress.Any, nexPort); @@ -191,11 +71,7 @@ async Task GetNextAvailablePort(TimeSpan delay = default) { await Task.Delay(delay); } finally { -#if NET - if (socket.Connected) await socket.DisconnectAsync(true); -#else if (socket.Connected) socket.Disconnect(true); -#endif } } } finally { @@ -203,5 +79,5 @@ async Task GetNextAvailablePort(TimeSpan delay = default) { } } - public int NextAvailablePort => GetNextAvailablePort(FromMilliseconds(100)).GetAwaiter().GetResult(); + public int NextAvailablePort => GetNextAvailablePort(100.Milliseconds()).GetAwaiter().GetResult(); } diff --git a/test/KurrentDB.Client.Tests.Common/FluentDocker/TestContainerService.cs b/test/KurrentDB.Client.Tests.Common/FluentDocker/TestContainerService.cs index 2056b21fd..908563ca0 100644 --- a/test/KurrentDB.Client.Tests.Common/FluentDocker/TestContainerService.cs +++ b/test/KurrentDB.Client.Tests.Common/FluentDocker/TestContainerService.cs @@ -1,6 +1,140 @@ +// ReSharper disable InconsistentNaming + +using System.Text.RegularExpressions; using Ductus.FluentDocker.Builders; using Ductus.FluentDocker.Services; +using System.Net; +using System.Net.Sockets; +using Ductus.FluentDocker.Extensions; +using Humanizer; +using Ductus.FluentDocker.Model.Builders; +using Ductus.FluentDocker.Services.Extensions; namespace KurrentDB.Client.Tests.FluentDocker; -public abstract class TestContainerService : TestService; +public abstract class TestContainerService : TestService { + static Version? _version; + // Define the regex pattern as a static readonly field instead of using GeneratedRegex + static readonly Regex _versionRegex = new Regex(@"\b(?:KurrentDB|EventStore)\s+version\s+([0-9]+(?:\.[0-9]+)*)", RegexOptions.Compiled); + + public static Version Version => _version ??= GetVersion(); + + internal static string ConnectionString => "kurrentdb://admin:changeit@localhost:{port}/?tlsVerifyCert=false"; + + protected static ContainerBuilder CreateContainer( + IDictionary environment, string containerName = "dotnet-client-test", int port = 2113 + ) { + var env = environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray(); + + var certsPath = Path.Combine(Environment.CurrentDirectory, "certs"); + + CertificatesManager.VerifyCertificatesExist(certsPath); + + return new Builder() + .UseContainer() + .UseImage(environment["TESTCONTAINER_KURRENTDB_IMAGE"]) + .WithName(containerName) + .WithPublicEndpointResolver() + .WithEnvironment(env) + .MountVolume(certsPath, "/etc/eventstore/certs", MountType.ReadOnly) + .ExposePort(port, 2113); + } + + protected static ContainerBuilder AddReadinessCheck(ContainerBuilder builder) { + return builder.WaitUntilReadyWithConstantBackoff( + 1_000, + 60, + service => { + var output = service.ExecuteCommand("curl -u admin:changeit --cacert /etc/eventstore/certs/ca/ca.crt https://localhost:2113/health/live"); + if (!output.Success) + throw new Exception(output.Error); + + var versionOutput = service.ExecuteCommand("/opt/kurrentdb/kurrentd --version"); + if (!versionOutput.Success) + versionOutput = service.ExecuteCommand("/opt/eventstore/eventstored --version"); + + if (versionOutput.Success && TryParseVersion(versionOutput.Log.FirstOrDefault(), out var version)) + _version ??= version; + } + ); + } + + /// Attempts to parse a version number from the given input string. + /// This method looks for a version pattern within the input string, such as + /// "KurrentDB version 25.1.0.2299-nightly" or "EventStore version 25.1.0.2299-nightly", + /// and tries to extract and parse the version information. + /// The input span containing the version string to parse. + /// When the method returns, contains the parsed object, or null if parsing failed. + /// true if the version could be successfully parsed; otherwise, false. + static bool TryParseVersion(ReadOnlySpan input, out Version? version) { + version = null; + + if (input.IsEmpty) { + return false; + } + + try { + // Use the static regex field instead of calling VersionRegex() method + var match = _versionRegex.Match(input.ToString()); + + if (!match.Success || match.Groups.Count < 2) + return false; + + return Version.TryParse(match.Groups[1].Value, out version); + } catch (Exception ex) { + throw new Exception($"Failed to parse version from: {input.ToString()}", ex); + } + } + + // static Version GetVersion() { + // using var cts = new CancellationTokenSource(30.Seconds()); + // using var database = new Builder().UseContainer() + // .UseImage(GlobalEnvironment.DockerImage) + // .Command("--version") + // .Build() + // .Start(); + // + // using var log = database.Logs(true, cts.Token); + // + // foreach (var line in log.ReadToEnd()) + // if (TryParseVersion(line, out var version)) + // return version; + // + // throw new InvalidOperationException("Could not determine server version from logs"); + // } + + static Version GetVersion() { + const string versionPrefix = "KurrentDB version"; + const string esdbVersionPrefix = "EventStoreDB version"; + + using var cts = new CancellationTokenSource(30.Seconds()); + using var eventstore = new Builder().UseContainer() + .UseImage(GlobalEnvironment.DockerImage) + .Command("--version") + .Build() + .Start(); + + using var log = eventstore.Logs(true, cts.Token); + var logs = log.ReadToEnd(); + foreach (var line in logs) { + if (line.StartsWith(versionPrefix) && + Version.TryParse(new string(ReadVersion(line[(versionPrefix.Length + 1)..]).ToArray()), out var version)) { + return version; + } + + if (line.StartsWith(esdbVersionPrefix) && + Version.TryParse(new string(ReadVersion(line[(esdbVersionPrefix.Length + 1)..]).ToArray()), out var esdbVersion)) { + return esdbVersion; + } + } + + throw new InvalidOperationException($"Could not determine server version from logs: {string.Join(Environment.NewLine, logs)}"); + + IEnumerable ReadVersion(string s) { + foreach (var c in s.TakeWhile(c => c == '.' || char.IsDigit(c))) { + yield return c; + } + } + } + +} diff --git a/test/KurrentDB.Client.Tests.Common/GlobalEnvironment.cs b/test/KurrentDB.Client.Tests.Common/GlobalEnvironment.cs index 6934aface..2ef7b6e5e 100644 --- a/test/KurrentDB.Client.Tests.Common/GlobalEnvironment.cs +++ b/test/KurrentDB.Client.Tests.Common/GlobalEnvironment.cs @@ -1,41 +1,48 @@ using System.Collections.Immutable; -using KurrentDB.Client; +using Humanizer; using Microsoft.Extensions.Configuration; namespace KurrentDB.Client.Tests; public static class GlobalEnvironment { + public static double MaxAppendSize => 2.Megabytes().Bytes; + static GlobalEnvironment() { EnsureDefaults(Application.Configuration); UseCluster = Application.Configuration.GetValue("ES_USE_CLUSTER"); UseExternalServer = Application.Configuration.GetValue("ES_USE_EXTERNAL_SERVER"); - DockerImage = Application.Configuration.GetValue("ES_DOCKER_IMAGE")!; + DockerImage = Application.Configuration.GetValue("TESTCONTAINER_KURRENTDB_IMAGE")!; Variables = Application.Configuration.AsEnumerable() - .Where(x => x.Key.StartsWith("ES_") || x.Key.StartsWith("EVENTSTORE_")) + .Where(x => x.Key.StartsWith("TESTCONTAINER_") || x.Key.StartsWith("EVENTSTORE_") || x.Key.StartsWith("KURRENTDB_")) .OrderBy(x => x.Key) .ToImmutableDictionary(x => x.Key, x => x.Value ?? string.Empty)!; return; static void EnsureDefaults(IConfiguration configuration) { - configuration.EnsureValue("ES_USE_CLUSTER", "false"); - configuration.EnsureValue("ES_USE_EXTERNAL_SERVER", "false"); - - configuration.EnsureValue("ES_DOCKER_REGISTRY", "docker.kurrent.io/eventstore/eventstoredb-ee"); - configuration.EnsureValue("ES_DOCKER_TAG", "lts"); - configuration.EnsureValue("ES_DOCKER_IMAGE", $"{configuration["ES_DOCKER_REGISTRY"]}:{configuration["ES_DOCKER_TAG"]}"); + // internal defaults + configuration.EnsureValue("TESTCONTAINER_KURRENTDB_IMAGE", "docker.cloudsmith.io/eventstore/kurrent-latest/kurrentdb:latest"); + // database defaults configuration.EnsureValue("EVENTSTORE_TELEMETRY_OPTOUT", "true"); configuration.EnsureValue("EVENTSTORE_ALLOW_UNKNOWN_OPTIONS", "true"); - configuration.EnsureValue("EVENTSTORE_MEM_DB", "false"); configuration.EnsureValue("EVENTSTORE_RUN_PROJECTIONS", "None"); configuration.EnsureValue("EVENTSTORE_START_STANDARD_PROJECTIONS", "false"); - configuration.EnsureValue("EVENTSTORE_LOG_LEVEL", "Information"); - configuration.EnsureValue("EVENTSTORE_DISABLE_LOG_FILE", "true"); + configuration.EnsureValue("EVENTSTORE_MEM_DB", "true"); + configuration.EnsureValue("EVENTSTORE_CERTIFICATE_FILE", "/etc/eventstore/certs/node/node.crt"); + configuration.EnsureValue("EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE", "/etc/eventstore/certs/node/node.key"); configuration.EnsureValue("EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH", "/etc/eventstore/certs/ca"); + configuration.EnsureValue("EVENTSTORE__PLUGINS__USERCERTIFICATES__ENABLED", "true"); + configuration.EnsureValue("EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE", "10000"); + configuration.EnsureValue("EVENTSTORE_STREAM_INFO_CACHE_CAPACITY", "10000"); configuration.EnsureValue("EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP", "true"); + configuration.EnsureValue("EVENTSTORE_LOG_LEVEL", "Default"); + configuration.EnsureValue("EVENTSTORE_DISABLE_LOG_FILE", "true"); + configuration.EnsureValue("EVENTSTORE_MAX_APPEND_SIZE", $"{MaxAppendSize}"); + configuration.EnsureValue("EVENTSTORE_MAX_APPEND_EVENT_SIZE", $"{MaxAppendSize}"); + } } diff --git a/test/KurrentDB.Client.Tests.Common/KurrentDB.Client.Tests.Common.csproj b/test/KurrentDB.Client.Tests.Common/KurrentDB.Client.Tests.Common.csproj index 6b1d9c628..29eb6b77c 100644 --- a/test/KurrentDB.Client.Tests.Common/KurrentDB.Client.Tests.Common.csproj +++ b/test/KurrentDB.Client.Tests.Common/KurrentDB.Client.Tests.Common.csproj @@ -45,9 +45,6 @@ Always - - Always - Always diff --git a/test/KurrentDB.Client.Tests.Common/shared.env b/test/KurrentDB.Client.Tests.Common/shared.env deleted file mode 100644 index 894e830d9..000000000 --- a/test/KurrentDB.Client.Tests.Common/shared.env +++ /dev/null @@ -1,16 +0,0 @@ -EVENTSTORE_CLUSTER_SIZE=3 -EVENTSTORE_INT_TCP_PORT=1112 -EVENTSTORE_HTTP_PORT=2113 -EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH=/etc/eventstore/certs/ca -EVENTSTORE_DISCOVER_VIA_DNS=false -EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=false -EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE=10000 - -# pass through from environment -EVENTSTORE_DB_LOG_FORMAT -EVENTSTORE_LOG_LEVEL -EVENTSTORE_MAX_APPEND_SIZE -EVENTSTORE_MAX_APPEND_EVENT_SIZE -EVENTSTORE_MEM_DB -EVENTSTORE_RUN_PROJECTIONS -EVENTSTORE_START_STANDARD_PROJECTIONS diff --git a/test/KurrentDB.Client.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs b/test/KurrentDB.Client.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs new file mode 100644 index 000000000..a2e95bb11 --- /dev/null +++ b/test/KurrentDB.Client.Tests/Diagnostics/PersistentSubscriptionsTracingInstrumentationTests.cs @@ -0,0 +1,158 @@ +using KurrentDB.Client.Tests.Fixtures; +using KurrentDB.Client.Tests.TestNode; +using KurrentDB.Diagnostics.Tracing; + +namespace KurrentDB.Client.Tests.Diagnostics; + +[Trait("Category", "Target:Diagnostics")] +public class PersistentSubscriptionsTracingInstrumentationTests(ITestOutputHelper output, DiagnosticsFixture fixture) + : KurrentDBPermanentTests(output, fixture) { + [RetryFact] + public async Task persistent_subscription_restores_remote_append_context() { + var traceId = Fixture.CreateTraceId(); + 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 + .GetActivities(TracingConstants.Operations.Append, traceId) + .SingleOrDefault() + .ShouldNotBeNull(); + + var subscribeActivities = Fixture + .GetActivities(TracingConstants.Operations.Subscribe, traceId) + .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; + } + } + } + + [RetryFact] + public async Task persistent_subscription_handles_non_json_events() { + var stream = Fixture.GetStreamName(); + var events = Fixture.CreateTestEvents( + 2, + metadata: Fixture.CreateTestJsonMetadata(), + contentType: Constants.Metadata.ContentTypes.ApplicationOctetStream + ).ToArray(); + + var groupName = $"{stream}-group"; + await Fixture.Subscriptions.CreateToStreamAsync( + stream, + groupName, + new() + ); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + events + ); + + await Subscribe().WithTimeout(); + + return; + + async Task Subscribe() { + await using var subscription = Fixture.Subscriptions.SubscribeToStream(stream, groupName); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + var eventsAppeared = 0; + while (await enumerator.MoveNextAsync()) { + if (enumerator.Current is PersistentSubscriptionMessage.Event(_, _)) + eventsAppeared++; + + if (eventsAppeared >= events.Length) + return; + } + } + } + + [RetryFact] + public async Task persistent_subscription_handles_invalid_json_metadata() { + var stream = Fixture.GetStreamName(); + var events = Fixture.CreateTestEvents( + 2, + metadata: "clearlynotavalidjsonobject"u8.ToArray() + ).ToArray(); + + var groupName = $"{stream}-group"; + await Fixture.Subscriptions.CreateToStreamAsync( + stream, + groupName, + new() + ); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + events + ); + + await Subscribe().WithTimeout(); + + return; + + async Task Subscribe() { + await using var subscription = Fixture.Subscriptions.SubscribeToStream(stream, groupName); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + var eventsAppeared = 0; + while (await enumerator.MoveNextAsync()) { + if (enumerator.Current is PersistentSubscriptionMessage.Event(_, _)) + eventsAppeared++; + + if (eventsAppeared >= events.Length) + return; + } + } + } +} diff --git a/test/KurrentDB.Client.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs b/test/KurrentDB.Client.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs new file mode 100644 index 000000000..8d4e07f82 --- /dev/null +++ b/test/KurrentDB.Client.Tests/Diagnostics/StreamsTracingInstrumentationTests.cs @@ -0,0 +1,338 @@ +// ReSharper disable AccessToDisposedClosure + +using System.Diagnostics; +using KurrentDB.Client.Diagnostics; +using KurrentDB.Client.Tests.Fixtures; +using KurrentDB.Diagnostics.Telemetry; +using KurrentDB.Diagnostics.Tracing; + +namespace KurrentDB.Client.Tests.Diagnostics; + +[Trait("Category", "Target:Diagnostics")] +public class StreamsTracingInstrumentationTests(ITestOutputHelper output, DiagnosticsFixture fixture) : KurrentDBPermanentTests(output, fixture) { + [Fact] + public async Task append_to_stream() { + var traceId = Fixture.CreateTraceId(); + + var stream = Fixture.GetStreamName(); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents() + ); + + var activity = Fixture + .GetActivities(TracingConstants.Operations.Append, traceId) + .SingleOrDefault() + .ShouldNotBeNull(); + + Fixture.AssertAppendActivityHasExpectedTags(activity, stream); + } + + [MinimumVersion.Fact(25, 1)] + public async Task multi_stream_append() { + // Arrange + var traceId = Fixture.CreateTraceId(); + + var seedEvents = Fixture.CreateTestEvents(10).ToList(); + + var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); + + var stream1 = Fixture.GetStreamName(); + var stream2 = Fixture.GetStreamName(); + + AppendStreamRequest[] requests = [new(stream1, StreamState.NoStream, seedEvents.Take(5)), new(stream2, StreamState.NoStream, seedEvents.Skip(5))]; + + // Act + var appendResult = await Fixture.Streams.MultiStreamAppendAsync(requests.ToAsyncEnumerable()); + + await using var subscription = Fixture.Streams.SubscribeToAll( + FromAll.Start, + filterOptions: new SubscriptionFilterOptions(StreamFilter.Prefix(stream1, stream2)) + ); + + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + await Subscribe().WithTimeout(); + + // Assert + appendResult.Position.ShouldBePositive(); + + var appendActivities = Fixture.GetActivities(TracingConstants.Operations.MultiAppend, traceId); + var subscribeActivities = Fixture.GetActivities(TracingConstants.Operations.Subscribe, traceId); + + appendActivities.ShouldNotBeEmpty(); + subscribeActivities.ShouldNotBeEmpty(); + + appendActivities.Count.ShouldBe(1); + subscribeActivities.Count.ShouldBe(10); + + // They also have the same duration + appendActivities.Select(x => x.Duration).Distinct().Count().ShouldBe(1); + + // Check that subscribe activities have the correct parent IDs inherited from append activities + subscribeActivities + .FirstOrDefault(x => x.ParentId == appendActivities.First().Id)?.ParentSpanId + .ShouldBe(appendActivities.First().SpanId); + + subscribeActivities + .FirstOrDefault(x => x.ParentId == appendActivities.Last().Id)?.ParentSpanId + .ShouldBe(appendActivities.Last().SpanId); + + subscribeActivities + .All(x => x.StartTimeUtc > appendActivities.First().StartTimeUtc) + .ShouldBeTrue(); + + Fixture.AssertMultiAppendActivityHasExpectedTags(appendActivities.First()); + Fixture.AssertSubscriptionActivityHasExpectedTags(subscribeActivities.First(), stream1, seedEvents.First().EventId.ToString()); + + return; + + async Task Subscribe() { + while (await enumerator.MoveNextAsync()) { + if (enumerator.Current is not StreamMessage.Event(var resolvedEvent)) + continue; + + availableEvents.Remove(resolvedEvent.Event.EventId); + + if (availableEvents.Count is 0) + return; + } + } + } + + [MinimumVersion.Fact(25, 1)] + public async Task multi_stream_append_with_exceptions() { + var traceId = Fixture.CreateTraceId(); + + // Arrange + var stream1 = Fixture.GetStreamName(); + var stream2 = Fixture.GetStreamName(); + + AppendStreamRequest[] requests = [ + new(stream1, StreamState.StreamExists, Fixture.CreateTestEvents()), + new(stream2, StreamState.StreamExists, Fixture.CreateTestEvents()) + ]; + + // Act + var appendTask = async () => await Fixture.Streams.MultiStreamAppendAsync(requests); + var rex = await appendTask.ShouldThrowAsync(); + + // Assert + var appendActivities = Fixture.GetActivities(TracingConstants.Operations.MultiAppend, traceId); + + appendActivities.ShouldNotBeEmpty(); + + appendActivities.Count.ShouldBe(1); + + var activity = appendActivities.FirstOrDefault().ShouldNotBeNull(); + activity.Status.ShouldBe(ActivityStatusCode.Error); + activity.Events.ShouldHaveSingleItem(); + + var activityEvent = activity.Events.First(); + + activityEvent.Name.ShouldBe(TelemetryTags.Exception.EventName); + activityEvent.Tags.Any(tag => tag.Key == TelemetryTags.Exception.Message).ShouldBeTrue(); + activityEvent.Tags.Any(tag => tag.Key == TelemetryTags.Exception.Stacktrace).ShouldBeTrue(); + activityEvent.Tags.Any(tag => tag.Key == TelemetryTags.Exception.Type && (string?)tag.Value == rex.GetType().FullName).ShouldBeTrue(); + } + + [Fact] + public async Task append_trace_tagged_with_error_on_exception() { + var traceId = Fixture.CreateTraceId(); + var stream = Fixture.GetStreamName(); + + var actualException = await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEventsThatThrowsException() + ).ShouldThrowAsync(); + + var activity = Fixture + .GetActivities(TracingConstants.Operations.Append, traceId) + .SingleOrDefault() + .ShouldNotBeNull(); + + Fixture.AssertErroneousAppendActivityHasExpectedTags(activity, actualException); + } + + [Fact] + public async Task tracing_context_injected_when_metadata_is_json() { + var traceId = Fixture.CreateTraceId(); + var stream = Fixture.GetStreamName(); + + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents(1, metadata: Fixture.CreateTestJsonMetadata()) + ); + + var activity = Fixture + .GetActivities(TracingConstants.Operations.Append, traceId) + .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 tracing_context_not_injected_when_metadata_not_json() { + 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); + } + + [Fact] + public async Task tracing_context_injected_when_event_not_json_but_metadata_json() { + var traceId = Fixture.CreateTraceId(); + var stream = Fixture.GetStreamName(); + + var inputMetadata = Fixture.CreateTestJsonMetadata().ToArray(); + await Fixture.Streams.AppendToStreamAsync( + stream, + StreamState.NoStream, + Fixture.CreateTestEvents( + metadata: inputMetadata, + contentType: Constants.Metadata.ContentTypes.ApplicationOctetStream + ) + ); + + var readResult = await Fixture.Streams + .ReadStreamAsync(Direction.Forwards, stream, StreamPosition.Start) + .ToListAsync(); + + var outputMetadata = readResult[0].OriginalEvent.Metadata.ToArray(); + outputMetadata.ShouldNotBe(inputMetadata); + + var appendActivities = Fixture.GetActivities(TracingConstants.Operations.Append, traceId); + + appendActivities.ShouldNotBeEmpty(); + } + + [Fact] + public async Task json_metadata_traced_non_json_metadata_not_traced() { + var traceId = Fixture.CreateTraceId(); + var streamName = Fixture.GetStreamName(); + + var seedEvents = new[] { + Fixture.CreateTestEvent(metadata: Fixture.CreateTestJsonMetadata()), + Fixture.CreateTestEvent(metadata: Fixture.CreateTestNonJsonMetadata()) + }; + + var availableEvents = new HashSet(seedEvents.Select(x => x.EventId)); + + await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents); + + await using var subscription = Fixture.Streams.SubscribeToStream(streamName, FromStream.Start); + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + var appendActivities = Fixture + .GetActivities(TracingConstants.Operations.Append, traceId) + .ShouldNotBeNull(); + + Assert.True(await enumerator.MoveNextAsync()); + + Assert.IsType(enumerator.Current); + + await Subscribe(enumerator).WithTimeout(); + + var subscribeActivities = Fixture + .GetActivities(TracingConstants.Operations.Subscribe, traceId) + .ToArray(); + + appendActivities.ShouldHaveSingleItem(); + + subscribeActivities.ShouldHaveSingleItem(); + + subscribeActivities.First().ParentId.ShouldBe(appendActivities.First().Id); + + var jsonMetadataEvent = seedEvents.First(); + + Fixture.AssertSubscriptionActivityHasExpectedTags( + subscribeActivities.First(), + streamName, + jsonMetadataEvent.EventId.ToString() + ); + + return; + + async Task Subscribe(IAsyncEnumerator internalEnumerator) { + while (await internalEnumerator.MoveNextAsync()) { + if (internalEnumerator.Current is not StreamMessage.Event(var resolvedEvent)) + continue; + + availableEvents.Remove(resolvedEvent.Event.EventId); + + if (availableEvents.Count == 0) + return; + } + } + } + + [RetryFact] + [Trait("Category", "Special cases")] + public async Task no_trace_when_event_is_null() { + var traceId = Fixture.CreateTraceId(); + var category = Guid.NewGuid().ToString("N"); + var streamName = category + "-123"; + + var seedEvents = Fixture.CreateTestEvents(type: $"{category}-{Fixture.GetStreamName()}").ToArray(); + await Fixture.Streams.AppendToStreamAsync(streamName, StreamState.NoStream, seedEvents); + + await Fixture.Streams.DeleteAsync(streamName, StreamState.StreamExists); + + await using var subscription = Fixture.Streams.SubscribeToStream("$ce-" + category, FromStream.Start, resolveLinkTos: true); + + await using var enumerator = subscription.Messages.GetAsyncEnumerator(); + + Assert.True(await enumerator.MoveNextAsync()); + + Assert.IsType(enumerator.Current); + + await Subscribe().WithTimeout(); + + var appendActivities = Fixture + .GetActivities(TracingConstants.Operations.Append, traceId) + .ShouldNotBeNull(); + + var subscribeActivities = Fixture + .GetActivities(TracingConstants.Operations.Subscribe, traceId) + .ToArray(); + + appendActivities.ShouldHaveSingleItem(); + subscribeActivities.ShouldBeEmpty(); + + return; + + async Task Subscribe() { + while (await enumerator.MoveNextAsync()) { + if (enumerator.Current is not StreamMessage.Event(var resolvedEvent)) + continue; + + if (resolvedEvent.Event?.EventType is "$metadata") + return; + } + } + } +} diff --git a/test/KurrentDB.Client.Tests/Streams/AppendTests.cs b/test/KurrentDB.Client.Tests/Streams/AppendTests.cs index b607020e4..405a80002 100644 --- a/test/KurrentDB.Client.Tests/Streams/AppendTests.cs +++ b/test/KurrentDB.Client.Tests/Streams/AppendTests.cs @@ -485,21 +485,21 @@ public async Task succeeds_when_size_is_less_than_max_append_size() { [RetryFact] public async Task fails_when_size_exceeds_max_append_size() { // Arrange - var maxAppendSize = (uint)100.Kilobytes().Bytes; - var stream = Fixture.GetStreamName(); - var eventsAppendSize = (uint)10.Megabytes().Bytes; + var stream = Fixture.GetStreamName(); + + var maxAppendSize = (uint)10.Megabytes().Bytes; // Act - var (events, size) = Fixture.CreateTestEventsUpToMaxSize(eventsAppendSize); + var (events, size) = Fixture.CreateTestEventsUpToMaxSize(maxAppendSize); // Assert - size.ShouldBeGreaterThan(maxAppendSize); + size.ShouldBeGreaterThan((uint)GlobalEnvironment.MaxAppendSize); var ex = await Fixture.Streams .AppendToStreamAsync(stream, StreamState.NoStream, events) .ShouldThrowAsync(); - ex.MaxAppendSize.ShouldBe(maxAppendSize); + ex.MaxAppendSize.ShouldBe((uint)GlobalEnvironment.MaxAppendSize); } [RetryFact] diff --git a/test/KurrentDB.Client.Tests/Streams/MultiStreamAppendTests.cs b/test/KurrentDB.Client.Tests/Streams/MultiStreamAppendTests.cs new file mode 100644 index 000000000..45bd38458 --- /dev/null +++ b/test/KurrentDB.Client.Tests/Streams/MultiStreamAppendTests.cs @@ -0,0 +1,112 @@ +namespace KurrentDB.Client.Tests.Streams; + +[Trait("Category", "Target:Streams")] +[Trait("Category", "Operation:MultiStreamAppend")] +public class MultiStreamAppendTests(ITestOutputHelper output, KurrentDBPermanentFixture fixture) : KurrentDBPermanentTests(output, fixture) { + [MinimumVersion.Fact(25, 1)] + public async Task append_events_with_invalid_metadata_format_throws_exceptions() { + // Arrange + var stream = Fixture.GetStreamName(); + + var invalidMetadata = "invalid"u8.ToArray(); + + AppendStreamRequest[] requests = [ + new(stream, StreamState.NoStream, Fixture.CreateTestEvents(3, metadata: invalidMetadata)) + ]; + + // Act & Assert + var exception = await Fixture.Streams + .MultiStreamAppendAsync(requests).AsTask() + .ShouldThrowAsync(); + + exception.Message.ShouldContain("Failed to decode event metadata"); + } + + [MinimumVersion.Fact(25, 1)] + public async Task append_events_to_multiple_streams() { + // Arrange + var stream1 = Fixture.GetStreamName(); + var stream2 = Fixture.GetStreamName(); + + var expectedMetadata = new Dictionary { + ["Name"] = Fixture.Faker.Person.FullName + }; + + AppendStreamRequest[] requests = [ + new(stream1, StreamState.NoStream, Fixture.CreateTestEvents(3, metadata: expectedMetadata.Encode()).ToArray()), + new(stream2, StreamState.NoStream, Fixture.CreateTestEvents(2, metadata: expectedMetadata.Encode()).ToArray()) + ]; + + // Act + var result = await Fixture.Streams.MultiStreamAppendAsync(requests); + + // Assert + result.Position.ShouldBePositive(); + result.Responses.ShouldNotBeEmpty(); + + result.Responses.First().Stream.ShouldBe(stream1); + result.Responses.First().StreamRevision.ShouldBePositive(); + + result.Responses.Last().Stream.ShouldBe(stream2); + result.Responses.Last().StreamRevision.ShouldBePositive(); + + var stream1Events = await Fixture.Streams + .ReadStreamAsync(Direction.Forwards, stream1, StreamPosition.Start, 10) + .ToArrayAsync(); + + var stream2Events = await Fixture.Streams + .ReadStreamAsync(Direction.Forwards, stream2, StreamPosition.Start, 10) + .ToArrayAsync(); + + var metadata = stream1Events.First().Decode(); + + stream1Events.Length.ShouldBe(3); + stream2Events.Length.ShouldBe(2); + + metadata.ShouldNotBeNull(); + metadata[Constants.Metadata.SchemaName].ShouldBe("test-event-type"); + metadata[Constants.Metadata.SchemaFormat].ShouldBe(nameof(SchemaDataFormat.Json)); + metadata["Name"].ShouldBe(expectedMetadata["Name"]); + } + + [MinimumVersion.Fact(25, 1)] + public async Task appending_events_with_stream_revision_conflicts() { + // Arrange + var stream1 = Fixture.GetStreamName(); + var stream2 = Fixture.GetStreamName(); + + AppendStreamRequest[] requests = [ + new(stream1, StreamState.StreamExists, Fixture.CreateTestEvents(3).ToArray()), + new(stream2, StreamState.StreamExists, Fixture.CreateTestEvents(3).ToArray()), + ]; + + // Act + var appendTask = async () => await Fixture.Streams.MultiStreamAppendAsync(requests); + + // Assert + var rex = await appendTask.ShouldThrowAsync(); + rex.ExpectedStreamState.ShouldBe(StreamState.StreamExists); + rex.ActualStreamState.ShouldBe(StreamState.NoStream); + } + + [MinimumVersion.Fact(25, 1)] + public async Task appending_events_throws_deleted_exception_when_tombstoned() { + // Arrange + var stream = Fixture.GetStreamName(); + + await Fixture.Streams.AppendToStreamAsync(stream, StreamState.NoStream, Fixture.CreateTestEvents()); + + await Fixture.Streams.TombstoneAsync(stream, StreamState.StreamExists); + + AppendStreamRequest[] requests = [ + new(stream, StreamState.NoStream, Fixture.CreateTestEvents(3).ToArray()) + ]; + + // Act + var appendTask = async () => await Fixture.Streams.MultiStreamAppendAsync(requests); + + // Assert + var rex = await appendTask.ShouldThrowAsync(); + rex.Stream.ShouldBe(stream); + } +} diff --git a/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs b/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs index 36b2948dd..e3cd258b1 100644 --- a/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs +++ b/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamBackwardTests.cs @@ -267,14 +267,14 @@ public async Task stream_found() { ).Messages.ToArrayAsync(); Assert.Equal( - eventCount + (Fixture.EventStoreHasLastStreamPosition ? 2 : 1), + eventCount + (Fixture.HasLastStreamPosition ? 2 : 1), result.Length ); Assert.Equal(StreamMessage.Ok.Instance, result[0]); Assert.Equal(eventCount, result.OfType().Count()); - if (Fixture.EventStoreHasLastStreamPosition) + if (Fixture.HasLastStreamPosition) Assert.Equal(new StreamMessage.LastStreamPosition(new(31)), result[^1]); } } diff --git a/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamForwardTests.cs b/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamForwardTests.cs index 05281ddc4..0a26451ac 100644 --- a/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamForwardTests.cs +++ b/test/KurrentDB.Client.Tests/Streams/Read/ReadStreamForwardTests.cs @@ -208,7 +208,7 @@ public async Task stream_found() { ).Messages.ToArrayAsync(); Assert.Equal( - eventCount + (Fixture.EventStoreHasLastStreamPosition ? 2 : 1), + eventCount + (Fixture.HasLastStreamPosition ? 2 : 1), result.Length ); @@ -216,10 +216,10 @@ public async Task stream_found() { Assert.Equal(eventCount, result.OfType().Count()); var first = Assert.IsType(result[1]); Assert.Equal(new(0), first.ResolvedEvent.OriginalEventNumber); - var last = Assert.IsType(result[Fixture.EventStoreHasLastStreamPosition ? ^2 : ^1]); + var last = Assert.IsType(result[Fixture.HasLastStreamPosition ? ^2 : ^1]); Assert.Equal(new((ulong)eventCount - 1), last.ResolvedEvent.OriginalEventNumber); - if (Fixture.EventStoreHasLastStreamPosition) + if (Fixture.HasLastStreamPosition) Assert.Equal( new StreamMessage.LastStreamPosition(new((ulong)eventCount - 1)), result[^1] @@ -249,18 +249,18 @@ await Fixture.Streams.SetStreamMetadataAsync( ).Messages.ToArrayAsync(); Assert.Equal( - eventCount - 32 + (Fixture.EventStoreHasLastStreamPosition ? 3 : 1), + eventCount - 32 + (Fixture.HasLastStreamPosition ? 3 : 1), result.Length ); Assert.Equal(StreamMessage.Ok.Instance, result[0]); - if (Fixture.EventStoreHasLastStreamPosition) + if (Fixture.HasLastStreamPosition) Assert.Equal(new StreamMessage.FirstStreamPosition(new(32)), result[1]); Assert.Equal(32, result.OfType().Count()); - if (Fixture.EventStoreHasLastStreamPosition) + if (Fixture.HasLastStreamPosition) Assert.Equal( new StreamMessage.LastStreamPosition(new((ulong)eventCount - 1)), result[^1] diff --git a/test/KurrentDB.Client.Tests/UserManagement/ListUserTests.cs b/test/KurrentDB.Client.Tests/UserManagement/ListUserTests.cs index ed1318135..4218877de 100644 --- a/test/KurrentDB.Client.Tests/UserManagement/ListUserTests.cs +++ b/test/KurrentDB.Client.Tests/UserManagement/ListUserTests.cs @@ -1,16 +1,15 @@ -using KurrentDB.Client; +// ReSharper disable InconsistentNaming namespace KurrentDB.Client.Tests; [Trait("Category", "Target:UserManagement")] public class ListUserTests(ITestOutputHelper output, KurrentDBPermanentFixture fixture) : KurrentDBPermanentTests(output, fixture) { - readonly string _userFullNamePrefix = fixture.IsKdb ? "KurrentDB" : "Event Store"; - [Fact] + [Fact(Skip = "Temporary")] public async Task returns_all_created_users() { var seed = await Fixture.CreateTestUsers(); - var admin = new UserDetails("admin", $"{_userFullNamePrefix} Administrator", new[] { "$admins" }, false, default); - var ops = new UserDetails("ops", $"{_userFullNamePrefix} Operations", new[] { "$ops" }, false, default); + var admin = new UserDetails("admin", "KurrentDB Administrator", ["$admins"], false, null); + var ops = new UserDetails("ops", "KurrentDB Operations", ["$ops"], false, null); var expected = new[] { admin, ops } .Concat(seed.Select(user => user.Details)) @@ -18,24 +17,24 @@ public async Task returns_all_created_users() { var actual = await Fixture.DBUsers .ListAllAsync(userCredentials: TestCredentials.Root) - .Select(user => new UserDetails(user.LoginName, user.FullName, user.Groups, user.Disabled, default)) + .Select(user => new UserDetails(user.LoginName, user.FullName, user.Groups, user.Disabled, null)) .ToArrayAsync(); - expected.ShouldBeSubsetOf(actual); + foreach (var user in expected) actual.ShouldContain(user); } - [Fact] + [Fact(Skip = "Temporary")] public async Task returns_all_system_users() { - var admin = new UserDetails("admin", $"{_userFullNamePrefix} Administrator", new[] { "$admins" }, false, default); - var ops = new UserDetails("ops", $"{_userFullNamePrefix} Operations", new[] { "$ops" }, false, default); + var admin = new UserDetails("admin", "KurrentDB Administrator", ["$admins"], false, null); + var ops = new UserDetails("ops", "KurrentDB Operations", ["$ops"], false, null); var expected = new[] { admin, ops }; var actual = await Fixture.DBUsers .ListAllAsync(userCredentials: TestCredentials.Root) - .Select(user => new UserDetails(user.LoginName, user.FullName, user.Groups, user.Disabled, default)) + .Select(user => new UserDetails(user.LoginName, user.FullName, user.Groups, user.Disabled, null)) .ToArrayAsync(); - expected.ShouldBeSubsetOf(actual); + foreach (var user in expected) actual.ShouldContain(user); } }