From 055dea87793e913db3b602d55041b092a2b4cbaf Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 8 Jun 2021 16:26:56 +0100 Subject: [PATCH 01/25] Fix comments in IConnectionConfigurationValues --- .../Configuration/IConnectionConfigurationValues.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Elasticsearch.Net/Configuration/IConnectionConfigurationValues.cs b/src/Elasticsearch.Net/Configuration/IConnectionConfigurationValues.cs index 1344b3b274e..f76c77b46ec 100644 --- a/src/Elasticsearch.Net/Configuration/IConnectionConfigurationValues.cs +++ b/src/Elasticsearch.Net/Configuration/IConnectionConfigurationValues.cs @@ -18,7 +18,7 @@ public interface IConnectionConfigurationValues : IDisposable /// Basic access authorization credentials to specify with all requests. /// /// - /// Cannot be used in conjuction with + /// Cannot be used in conjunction with /// BasicAuthenticationCredentials BasicAuthenticationCredentials { get; } @@ -26,11 +26,11 @@ public interface IConnectionConfigurationValues : IDisposable /// Api Key authorization credentials to specify with all requests. /// /// - /// Cannot be used in conjuction with + /// Cannot be used in conjunction with /// ApiKeyAuthenticationCredentials ApiKeyAuthenticationCredentials { get; } - /// Provides a semaphoreslim to transport implementations that need to limit access to a resource + /// Provides a to transport implementations that need to limit access to a resource SemaphoreSlim BootstrapLock { get; } /// From 931386411c5f5ccc454c3a3d7d9b75d293fefbaa Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 11:46:35 +0100 Subject: [PATCH 02/25] Fix test method name typo --- tests/Tests/ClientConcepts/VirtualClusterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tests/ClientConcepts/VirtualClusterTests.cs b/tests/Tests/ClientConcepts/VirtualClusterTests.cs index 156840597f4..f88f3378d8e 100644 --- a/tests/Tests/ClientConcepts/VirtualClusterTests.cs +++ b/tests/Tests/ClientConcepts/VirtualClusterTests.cs @@ -30,7 +30,7 @@ [U] public async Task ThrowsExceptionWithNoRules() e.Message.Should().Contain("No ClientCalls defined for the current VirtualCluster, so we do not know how to respond"); } - [U] public async Task ThrowsExceptionAfterDepleedingRules() + [U] public async Task ThrowsExceptionAfterDepletingRules() { var audit = new Auditor(() => VirtualClusterWith .Nodes(1) From 7969d6f21b68693ebc0b342b7cb6af8dd383956a Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 14:39:01 +0100 Subject: [PATCH 03/25] Add audit event types --- src/Elasticsearch.Net/Auditing/AuditEvent.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Elasticsearch.Net/Auditing/AuditEvent.cs b/src/Elasticsearch.Net/Auditing/AuditEvent.cs index 90716aa1e86..b09d2cf1575 100644 --- a/src/Elasticsearch.Net/Auditing/AuditEvent.cs +++ b/src/Elasticsearch.Net/Auditing/AuditEvent.cs @@ -29,6 +29,10 @@ public enum AuditEvent NoNodesAttempted, CancellationRequested, FailedOverAllNodes, + + ProductCheckOnStartup, + ProductCheckSuccess, + ProductCheckFailure } internal static class AuditEventExtensions @@ -61,11 +65,11 @@ public static string GetAuditDiagnosticEventName(this AuditEvent @event) case NoNodesAttempted: return nameof(NoNodesAttempted); case CancellationRequested: return nameof(CancellationRequested); case FailedOverAllNodes: return nameof(FailedOverAllNodes); + case ProductCheckOnStartup: return nameof(ProductCheckOnStartup); + case ProductCheckSuccess: return nameof(ProductCheckSuccess); + case ProductCheckFailure: return nameof(ProductCheckFailure); default: return @event.GetStringValue(); //still cached but uses reflection } } - - } - } From 1a10d3d99585626c99067ab38138c9b70df9fd6d Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 14:42:57 +0100 Subject: [PATCH 04/25] Support product name of ApiCallDetails --- .../Responses/ElasticsearchResponse.cs | 12 ++++++++ .../Responses/HttpDetails/ApiCallDetails.cs | 4 +++ .../Responses/HttpDetails/IApiCallDetails.cs | 11 +++++++ .../Responses/ResponseStatics.cs | 8 +++-- .../Transport/Pipeline/ResponseBuilder.cs | 29 ++++++++++++++----- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/Elasticsearch.Net/Responses/ElasticsearchResponse.cs b/src/Elasticsearch.Net/Responses/ElasticsearchResponse.cs index 18f335e5c44..1463b3d6196 100644 --- a/src/Elasticsearch.Net/Responses/ElasticsearchResponse.cs +++ b/src/Elasticsearch.Net/Responses/ElasticsearchResponse.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Net.NetworkInformation; +using System.Text; using Elasticsearch.Net.Diagnostics; namespace Elasticsearch.Net @@ -45,20 +46,31 @@ public ReadOnlyDictionary ThreadPoolStats /// public IEnumerable DeprecationWarnings => ApiCall.DeprecationWarnings; + + /// + public string ProductName => ApiCall.ProductName; + /// public bool SuccessOrKnownError => ApiCall.SuccessOrKnownError; + /// public int? HttpStatusCode => ApiCall.HttpStatusCode; /// public bool Success => ApiCall.Success; + /// public Exception OriginalException => ApiCall.OriginalException; + /// public string ResponseMimeType => ApiCall.ResponseMimeType; + /// public Uri Uri => ApiCall.Uri; + /// + public Action BuildDebugInformationPrefix => ApiCall.BuildDebugInformationPrefix; + /// public IConnectionConfigurationValues ConnectionConfiguration => ApiCall.ConnectionConfiguration; diff --git a/src/Elasticsearch.Net/Responses/HttpDetails/ApiCallDetails.cs b/src/Elasticsearch.Net/Responses/HttpDetails/ApiCallDetails.cs index 4737b20f106..1278efefb33 100644 --- a/src/Elasticsearch.Net/Responses/HttpDetails/ApiCallDetails.cs +++ b/src/Elasticsearch.Net/Responses/HttpDetails/ApiCallDetails.cs @@ -28,6 +28,7 @@ public string DebugInformation var sb = new StringBuilder(); sb.AppendLine(ToString()); + _debugInformation = ResponseStatics.DebugInformationBuilder(this, sb); return _debugInformation; @@ -35,6 +36,7 @@ public string DebugInformation } public IEnumerable DeprecationWarnings { get; set; } + public string ProductName { get; set; } public HttpMethod HttpMethod { get; set; } public int? HttpStatusCode { get; set; } public Exception OriginalException { get; set; } @@ -50,6 +52,8 @@ public string DebugInformation && HttpStatusCode != 502; public Uri Uri { get; set; } + + public Action BuildDebugInformationPrefix { get; set; } public IConnectionConfigurationValues ConnectionConfiguration { get; set; } diff --git a/src/Elasticsearch.Net/Responses/HttpDetails/IApiCallDetails.cs b/src/Elasticsearch.Net/Responses/HttpDetails/IApiCallDetails.cs index c2e68970db1..50822dc08b5 100644 --- a/src/Elasticsearch.Net/Responses/HttpDetails/IApiCallDetails.cs +++ b/src/Elasticsearch.Net/Responses/HttpDetails/IApiCallDetails.cs @@ -7,6 +7,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Net.NetworkInformation; +using System.Text; using Elasticsearch.Net.Diagnostics; namespace Elasticsearch.Net @@ -50,6 +51,11 @@ public interface IApiCallDetails /// IEnumerable DeprecationWarnings { get; } + /// + /// The product name, if present in the 'X-elastic-product' response header. + /// + string ProductName { get; } + /// /// The HTTP method used by the request /// @@ -98,5 +104,10 @@ public interface IApiCallDetails /// The url as requested /// Uri Uri { get; } + + /// + /// A delegate which may render a prefix to the debug information. + /// + Action BuildDebugInformationPrefix { get; } } } diff --git a/src/Elasticsearch.Net/Responses/ResponseStatics.cs b/src/Elasticsearch.Net/Responses/ResponseStatics.cs index ccc133ddc40..97023116be8 100644 --- a/src/Elasticsearch.Net/Responses/ResponseStatics.cs +++ b/src/Elasticsearch.Net/Responses/ResponseStatics.cs @@ -20,6 +20,8 @@ public static class ResponseStatics public static string DebugInformationBuilder(IApiCallDetails r, StringBuilder sb) { + r.BuildDebugInformationPrefix?.Invoke(sb); + if (r.DeprecationWarnings.HasAny()) { sb.AppendLine($"# Server indicated deprecations:"); @@ -107,8 +109,10 @@ private static void AuditNodeUrl(StringBuilder sb, Audit audit) if (!string.IsNullOrEmpty(uri.UserInfo)) { - var builder = new UriBuilder(uri); - builder.Password = "redacted"; + var builder = new UriBuilder(uri) + { + Password = "redacted" + }; uri = builder.Uri; } sb.Append($" Node: {uri}"); diff --git a/src/Elasticsearch.Net/Transport/Pipeline/ResponseBuilder.cs b/src/Elasticsearch.Net/Transport/Pipeline/ResponseBuilder.cs index afa2a05db3f..1806a682a16 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/ResponseBuilder.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/ResponseBuilder.cs @@ -27,12 +27,14 @@ public static TResponse ToResponse( int? statusCode, IEnumerable warnings, Stream responseStream, - string mimeType = null + string mimeType = null, + string productName = null ) where TResponse : class, IElasticsearchResponse, new() { responseStream.ThrowIfNull(nameof(responseStream)); - var details = Initialize(requestData, ex, statusCode, warnings, mimeType); + + var details = Initialize(requestData, ex, statusCode, warnings, mimeType, productName); //TODO take ex and (responseStream == Stream.Null) into account might not need to flow to SetBody in that case @@ -55,12 +57,16 @@ public static async Task ToResponseAsync( IEnumerable warnings, Stream responseStream, string mimeType = null, + string productName = null, CancellationToken cancellationToken = default ) where TResponse : class, IElasticsearchResponse, new() { responseStream.ThrowIfNull(nameof(responseStream)); - var details = Initialize(requestData, ex, statusCode, warnings, mimeType); + + //TODO take ex and (responseStream == Stream.Null) into account might not need to flow to SetBody in that case + + var details = Initialize(requestData, ex, statusCode, warnings, mimeType, productName); TResponse response = null; @@ -81,8 +87,12 @@ private static bool MayHaveBody(int? statusCode, HttpMethod httpMethod) => !statusCode.HasValue || (statusCode.Value != 204 && httpMethod != HttpMethod.HEAD); private static ApiCallDetails Initialize( - RequestData requestData, Exception exception, int? statusCode, IEnumerable warnings, string mimeType - ) + RequestData requestData, + Exception exception, + int? statusCode, + IEnumerable warnings, + string mimeType, + string productName) { var success = false; var allowedStatusCodes = requestData.AllowedStatusCodes; @@ -110,7 +120,8 @@ private static ApiCallDetails Initialize( HttpMethod = requestData.Method, DeprecationWarnings = warnings ?? Enumerable.Empty(), ResponseMimeType = mimeType, - ConnectionConfiguration = requestData.ConnectionSettings + ConnectionConfiguration = requestData.ConnectionSettings, + ProductName = productName }; return details; } @@ -199,7 +210,11 @@ private static bool SetSpecialTypes(string mimeType, byte[] bytes, IM //if not json store the result under "body" if (!RequestData.IsJsonMimeType(mimeType)) { - var dictionary = new DynamicDictionary { ["body"] = new(bytes.Utf8String()) }; + var dictionary = new DynamicDictionary + { + ["body"] = new(bytes.Utf8String()) + }; + cs = new DynamicResponse(dictionary) as TResponse; } else From f0558435e66876588248def4fc000b51293781d2 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 14:49:23 +0100 Subject: [PATCH 05/25] Add types for response deserialisation --- .../Responses/RootPath/BuildVersion.cs | 26 +++++++++++++++++++ .../Responses/RootPath/RootResponse.cs | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/Elasticsearch.Net/Responses/RootPath/BuildVersion.cs create mode 100644 src/Elasticsearch.Net/Responses/RootPath/RootResponse.cs diff --git a/src/Elasticsearch.Net/Responses/RootPath/BuildVersion.cs b/src/Elasticsearch.Net/Responses/RootPath/BuildVersion.cs new file mode 100644 index 00000000000..16493de0961 --- /dev/null +++ b/src/Elasticsearch.Net/Responses/RootPath/BuildVersion.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Runtime.Serialization; + +namespace Elasticsearch.Net +{ + /// + /// Information about the build version. + /// + internal class BuildVersion + { + /// + /// The SemVer version number, which may include pre-release labels. + /// + [DataMember(Name = "number")] + public string Number { get; set; } + + /// + /// The flavor of the build. + /// + [DataMember(Name = "build_flavor")] + public string BuildFlavor { get; set; } + } +} diff --git a/src/Elasticsearch.Net/Responses/RootPath/RootResponse.cs b/src/Elasticsearch.Net/Responses/RootPath/RootResponse.cs new file mode 100644 index 00000000000..e4d29d864bc --- /dev/null +++ b/src/Elasticsearch.Net/Responses/RootPath/RootResponse.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Runtime.Serialization; + +namespace Elasticsearch.Net +{ + /// + /// Represents the response from a call to the root path of an Elasticsearch server. + /// + internal class RootResponse : ElasticsearchResponseBase + { + /// + /// The build version information of the product. + /// + [DataMember(Name = "version")] + public BuildVersion Version { get; set; } + + /// + /// The tagline of the product. + /// + [DataMember(Name = "tagline")] + public string Tagline { get; set; } + } +} From 09a1b5d19d7f466860e9f7dd52503682a2c7199a Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:12:20 +0100 Subject: [PATCH 06/25] Add product check status to connection pool --- .../ConnectionPool/IConnectionPool.cs | 14 ++++++++++---- .../ConnectionPool/SingleNodeConnectionPool.cs | 3 +++ .../ConnectionPool/StaticConnectionPool.cs | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Elasticsearch.Net/ConnectionPool/IConnectionPool.cs b/src/Elasticsearch.Net/ConnectionPool/IConnectionPool.cs index 50948e61dfa..8378461afac 100644 --- a/src/Elasticsearch.Net/ConnectionPool/IConnectionPool.cs +++ b/src/Elasticsearch.Net/ConnectionPool/IConnectionPool.cs @@ -10,21 +10,21 @@ namespace Elasticsearch.Net public interface IConnectionPool : IDisposable { /// - /// The last time that this instance was updated + /// The last time that this instance was updated. /// DateTime LastUpdate { get; } /// /// Returns the default maximum retries for the connection pool implementation. - /// Most implementation default to number of nodes, note that this can be overidden - /// in the connection settings + /// Most implementation default to number of nodes, note that this can be overridden + /// in the connection settings. /// int MaxRetries { get; } /// /// Returns a read only view of all the nodes in the cluster, which might involve creating copies of nodes e.g /// if you are using . - /// If you do not need an isolated copy of the nodes, please read to completion + /// If you do not need an isolated copy of the nodes, please read to completion. /// IReadOnlyCollection Nodes { get; } @@ -34,6 +34,12 @@ public interface IConnectionPool : IDisposable /// bool SniffedOnStartup { get; set; } + /// + /// Whether a product check is seen on startup. The implementation is + /// responsible for setting this in a thread safe fashion. + /// + ProductCheckStatus ProductCheckStatus { get; set; } + /// /// Whether pinging is supported /// diff --git a/src/Elasticsearch.Net/ConnectionPool/SingleNodeConnectionPool.cs b/src/Elasticsearch.Net/ConnectionPool/SingleNodeConnectionPool.cs index ae4d6139966..c79552263bf 100644 --- a/src/Elasticsearch.Net/ConnectionPool/SingleNodeConnectionPool.cs +++ b/src/Elasticsearch.Net/ConnectionPool/SingleNodeConnectionPool.cs @@ -34,6 +34,9 @@ public bool SniffedOnStartup set { } } + /// + public ProductCheckStatus ProductCheckStatus { get; set; } + /// public bool SupportsPinging => false; diff --git a/src/Elasticsearch.Net/ConnectionPool/StaticConnectionPool.cs b/src/Elasticsearch.Net/ConnectionPool/StaticConnectionPool.cs index bdb01e247a0..d6ded72722d 100644 --- a/src/Elasticsearch.Net/ConnectionPool/StaticConnectionPool.cs +++ b/src/Elasticsearch.Net/ConnectionPool/StaticConnectionPool.cs @@ -75,6 +75,9 @@ private void Initialize(IEnumerable nodes, IDateTimeProvider dateTimeProvi /// public bool SniffedOnStartup { get; set; } + /// + public ProductCheckStatus ProductCheckStatus { get; set; } + /// public virtual bool SupportsPinging => true; From 7921c86fe85bbf72a76ed8136742575400b58b9a Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:13:44 +0100 Subject: [PATCH 07/25] Read product name from HTTP responses --- .../Connection/HttpConnection.cs | 12 ++++++++---- .../Connection/HttpWebRequestConnection.cs | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Elasticsearch.Net/Connection/HttpConnection.cs b/src/Elasticsearch.Net/Connection/HttpConnection.cs index 634d2a911bc..fce30d75a80 100644 --- a/src/Elasticsearch.Net/Connection/HttpConnection.cs +++ b/src/Elasticsearch.Net/Connection/HttpConnection.cs @@ -65,6 +65,7 @@ public virtual TResponse Request(RequestData requestData) HttpResponseMessage responseMessage; int? statusCode = null; IEnumerable warnings = null; + IEnumerable productNames = null; Stream responseStream = null; Exception ex = null; string mimeType = null; @@ -95,6 +96,7 @@ public virtual TResponse Request(RequestData requestData) requestData.MadeItToResponse = true; responseMessage.Headers.TryGetValues("Warning", out warnings); + responseMessage.Headers.TryGetValues("X-elastic-product", out productNames); mimeType = responseMessage.Content.Headers.ContentType?.ToString(); if (responseMessage.Content != null) @@ -114,7 +116,7 @@ public virtual TResponse Request(RequestData requestData) using(receive) using (responseStream ??= Stream.Null) { - var response = ResponseBuilder.ToResponse(requestData, ex, statusCode, warnings, responseStream, mimeType); + var response = ResponseBuilder.ToResponse(requestData, ex, statusCode, warnings, responseStream, mimeType, productNames?.FirstOrDefault()); // set TCP and threadpool stats on the response here so that in the event the request fails after the point of // gathering stats, they are still exposed on the call details. Ideally these would be set inside ResponseBuilder.ToResponse, @@ -132,6 +134,7 @@ public virtual async Task RequestAsync(RequestData request HttpResponseMessage responseMessage; int? statusCode = null; IEnumerable warnings = null; + IEnumerable productNames = null; Stream responseStream = null; Exception ex = null; string mimeType = null; @@ -164,6 +167,7 @@ public virtual async Task RequestAsync(RequestData request requestData.MadeItToResponse = true; mimeType = responseMessage.Content.Headers.ContentType?.ToString(); responseMessage.Headers.TryGetValues("Warning", out warnings); + responseMessage.Headers.TryGetValues("X-elastic-product", out productNames); if (responseMessage.Content != null) { @@ -180,13 +184,13 @@ public virtual async Task RequestAsync(RequestData request ex = e; } using (receive) - using (responseStream = responseStream ?? Stream.Null) + using (responseStream ??= Stream.Null) { var response = await ResponseBuilder.ToResponseAsync - (requestData, ex, statusCode, warnings, responseStream, mimeType, cancellationToken) + (requestData, ex, statusCode, warnings, responseStream, mimeType, productNames?.FirstOrDefault(), cancellationToken) .ConfigureAwait(false); - // set TCP and threadpool stats on the response here so that in the event the request fails after the point of + // set TCP and thread pool stats on the response here so that in the event the request fails after the point of // gathering stats, they are still exposed on the call details. Ideally these would be set inside ResponseBuilder.ToResponse, // but doing so would be a breaking change in 7.x response.ApiCall.TcpStats = tcpStats; diff --git a/src/Elasticsearch.Net/Connection/HttpWebRequestConnection.cs b/src/Elasticsearch.Net/Connection/HttpWebRequestConnection.cs index 071f1a03486..bc16562a6a2 100644 --- a/src/Elasticsearch.Net/Connection/HttpWebRequestConnection.cs +++ b/src/Elasticsearch.Net/Connection/HttpWebRequestConnection.cs @@ -36,6 +36,7 @@ public virtual TResponse Request(RequestData requestData) { int? statusCode = null; IEnumerable warnings = null; + IEnumerable productNames = null; Stream responseStream = null; Exception ex = null; string mimeType = null; @@ -76,6 +77,10 @@ public virtual TResponse Request(RequestData requestData) //response.Headers.HasKeys() can return false even if response.Headers.AllKeys has values. if (httpWebResponse.SupportsHeaders && httpWebResponse.Headers.Count > 0 && httpWebResponse.Headers.AllKeys.Contains("Warning")) warnings = httpWebResponse.Headers.GetValues("Warning"); + + //response.Headers.HasKeys() can return false even if response.Headers.AllKeys has values. + if (httpWebResponse.SupportsHeaders && httpWebResponse.Headers.Count > 0 && httpWebResponse.Headers.AllKeys.Contains("X-elastic-product")) + productNames = httpWebResponse.Headers.GetValues("X-elastic-product"); } catch (WebException e) { @@ -85,7 +90,7 @@ public virtual TResponse Request(RequestData requestData) } responseStream ??= Stream.Null; - var response = ResponseBuilder.ToResponse(requestData, ex, statusCode, warnings, responseStream, mimeType); + var response = ResponseBuilder.ToResponse(requestData, ex, statusCode, warnings, responseStream, mimeType, productNames.FirstOrDefault()); // set TCP and threadpool stats on the response here so that in the event the request fails after the point of // gathering stats, they are still exposed on the call details. Ideally these would be set inside ResponseBuilder.ToResponse, @@ -103,6 +108,7 @@ CancellationToken cancellationToken Action unregisterWaitHandle = null; int? statusCode = null; IEnumerable warnings = null; + IEnumerable productNames = null; Stream responseStream = null; Exception ex = null; string mimeType = null; @@ -151,6 +157,10 @@ CancellationToken cancellationToken HandleResponse(httpWebResponse, out statusCode, out responseStream, out mimeType); if (httpWebResponse.SupportsHeaders && httpWebResponse.Headers.HasKeys() && httpWebResponse.Headers.AllKeys.Contains("Warning")) warnings = httpWebResponse.Headers.GetValues("Warning"); + + //response.Headers.HasKeys() can return false even if response.Headers.AllKeys has values. + if (httpWebResponse.SupportsHeaders && httpWebResponse.Headers.Count > 0 && httpWebResponse.Headers.AllKeys.Contains("X-elastic-product")) + productNames = httpWebResponse.Headers.GetValues("X-elastic-product"); } } catch (WebException e) @@ -165,10 +175,10 @@ CancellationToken cancellationToken } responseStream ??= Stream.Null; var response = await ResponseBuilder.ToResponseAsync - (requestData, ex, statusCode, warnings, responseStream, mimeType, cancellationToken) + (requestData, ex, statusCode, warnings, responseStream, mimeType, productNames.FirstOrDefault(), cancellationToken) .ConfigureAwait(false); - // set TCP and threadpool stats on the response here so that in the event the request fails after the point of + // set TCP and thread pool stats on the response here so that in the event the request fails after the point of // gathering stats, they are still exposed on the call details. Ideally these would be set inside ResponseBuilder.ToResponse, // but doing so would be a breaking change in 7.x response.ApiCall.TcpStats = tcpStats; From ffc91f4cc6ea7993419e0a995c822b98593dfe6b Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:14:48 +0100 Subject: [PATCH 08/25] Update InMemoryConnection to support product checks --- .../Connection/InMemoryConnection.cs | 149 ++++++++++++++---- 1 file changed, 122 insertions(+), 27 deletions(-) diff --git a/src/Elasticsearch.Net/Connection/InMemoryConnection.cs b/src/Elasticsearch.Net/Connection/InMemoryConnection.cs index 0d47859b767..01973ce330f 100644 --- a/src/Elasticsearch.Net/Connection/InMemoryConnection.cs +++ b/src/Elasticsearch.Net/Connection/InMemoryConnection.cs @@ -3,33 +3,95 @@ // See the LICENSE file in the project root for more information using System; +using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Elasticsearch.Net { + public class InMemoryHttpResponse + { + public int StatusCode { get; set; } = 200; + public byte[] ResponseBytes { get; set; } = Array.Empty(); + public Dictionary> Headers { get; set; } = new(); + public string ContentType { get; set; } + } + public class InMemoryConnection : IConnection { - internal static readonly byte[] EmptyBody = Encoding.UTF8.GetBytes(""); + private readonly string _basePath = "/"; + private const string DefaultProductName = "Elasticsearch"; + private static readonly byte[] EmptyBody = Encoding.UTF8.GetBytes(""); private readonly string _contentType; private readonly Exception _exception; private readonly byte[] _responseBody; private readonly int _statusCode; + private readonly InMemoryHttpResponse _productCheckResponse; + private readonly string _productHeader; + + public static InMemoryHttpResponse ValidProductCheckResponse(string productName = null) + { + var responseJson = new + { + name = "es01", + cluster_name = "elasticsearch-test-cluster", + version = new + { + number = "7.14.0", + build_flavor = "default", + build_hash = "af1dc6d8099487755c3143c931665b709de3c764", + build_timestamp = "2020-08-11T21:36:48.204330Z", + build_snapshot = false, + lucene_version = "8.6.0" + }, + tagline = "You Know, for Search" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var response = new InMemoryHttpResponse + { + ResponseBytes = ms.ToArray() + }; + + response.Headers.Add("X-elastic-product", new List{ productName ?? DefaultProductName }); + + return response; + } /// /// Every request will succeed with this overload, note that it won't actually return mocked responses /// so using this overload might fail if you are using it to test high level bits that need to deserialize the response. /// - public InMemoryConnection() => _statusCode = 200; + public InMemoryConnection() + { + _statusCode = 200; + _productCheckResponse = ValidProductCheckResponse(); + } - public InMemoryConnection(byte[] responseBody, int statusCode = 200, Exception exception = null, string contentType = null) + public InMemoryConnection(string basePath) : this() => _basePath = $"/{basePath.Trim('/')}/"; + + public InMemoryConnection(InMemoryHttpResponse productCheckResponse = null, int statusCode = 200, string productHeader = null) { + _statusCode = statusCode; + _productCheckResponse = productCheckResponse ?? ValidProductCheckResponse(); + _productHeader = productHeader; + } + public InMemoryConnection( + byte[] responseBody, + int statusCode = 200, + Exception exception = null, + string contentType = null, + InMemoryHttpResponse productCheckResponse = null, + string productNameFromHeader = null) : this(productCheckResponse, statusCode, productNameFromHeader) + { _responseBody = responseBody; - _statusCode = statusCode; _exception = exception; _contentType = contentType ?? RequestData.DefaultJsonMimeType; } @@ -43,25 +105,33 @@ public virtual Task RequestAsync(RequestData requestData, ReturnConnectionStatusAsync(requestData, cancellationToken); void IDisposable.Dispose() => DisposeManagedResources(); - - protected TResponse ReturnConnectionStatus(RequestData requestData, byte[] responseBody = null, int? statusCode = null, - string contentType = null + + protected TResponse ReturnConnectionStatus( + RequestData requestData, + byte[] responseBody = null, + int? statusCode = null, + string contentType = null, + InMemoryHttpResponse productCheckResponse = null ) where TResponse : class, IElasticsearchResponse, new() { + if (_basePath.Equals(requestData.Uri.AbsolutePath, StringComparison.Ordinal) && requestData.Method == HttpMethod.GET) + return ReturnProductCheckResponse(requestData, statusCode, productCheckResponse); + var body = responseBody ?? _responseBody; var data = requestData.PostData; - if (data != null) + + if (data is not null) { - using (var stream = requestData.MemoryStreamFactory.Create()) - { - if (requestData.HttpCompression) - using (var zipStream = new GZipStream(stream, CompressionMode.Compress)) - data.Write(zipStream, requestData.ConnectionSettings); - else - data.Write(stream, requestData.ConnectionSettings); - } + using var stream = requestData.MemoryStreamFactory.Create(); + + if (requestData.HttpCompression) + using (var zipStream = new GZipStream(stream, CompressionMode.Compress)) + data.Write(zipStream, requestData.ConnectionSettings); + else + data.Write(stream, requestData.ConnectionSettings); } + requestData.MadeItToResponse = true; var sc = statusCode ?? _statusCode; @@ -69,33 +139,58 @@ protected TResponse ReturnConnectionStatus(RequestData requestData, b return ResponseBuilder.ToResponse(requestData, _exception, sc, null, s, contentType ?? _contentType ?? RequestData.DefaultJsonMimeType); } - protected async Task ReturnConnectionStatusAsync(RequestData requestData, CancellationToken cancellationToken, - byte[] responseBody = null, int? statusCode = null, string contentType = null + protected async Task ReturnConnectionStatusAsync( + RequestData requestData, + CancellationToken cancellationToken, + byte[] responseBody = null, + int? statusCode = null, + string contentType = null, + InMemoryHttpResponse productCheckResponse = null ) where TResponse : class, IElasticsearchResponse, new() { + if (_basePath.Equals(requestData.Uri.AbsolutePath, StringComparison.Ordinal) && requestData.Method == HttpMethod.GET) + return ReturnProductCheckResponse(requestData, statusCode, productCheckResponse); + var body = responseBody ?? _responseBody; var data = requestData.PostData; + if (data != null) { - using (var stream = requestData.MemoryStreamFactory.Create()) - { - if (requestData.HttpCompression) - using (var zipStream = new GZipStream(stream, CompressionMode.Compress)) - await data.WriteAsync(zipStream, requestData.ConnectionSettings, cancellationToken).ConfigureAwait(false); - else - await data.WriteAsync(stream, requestData.ConnectionSettings, cancellationToken).ConfigureAwait(false); - } + using var stream = requestData.MemoryStreamFactory.Create(); + if (requestData.HttpCompression) + using (var zipStream = new GZipStream(stream, CompressionMode.Compress)) + await data.WriteAsync(zipStream, requestData.ConnectionSettings, cancellationToken).ConfigureAwait(false); + else + await data.WriteAsync(stream, requestData.ConnectionSettings, cancellationToken).ConfigureAwait(false); } requestData.MadeItToResponse = true; var sc = statusCode ?? _statusCode; Stream s = body != null ? requestData.MemoryStreamFactory.Create(body) : requestData.MemoryStreamFactory.Create(EmptyBody); return await ResponseBuilder - .ToResponseAsync(requestData, _exception, sc, null, s, contentType ?? _contentType, cancellationToken) + .ToResponseAsync(requestData, _exception, sc, null, s, contentType ?? _contentType, _productHeader, cancellationToken) .ConfigureAwait(false); } + private TResponse ReturnProductCheckResponse( + RequestData requestData, + int? statusCode = null, + InMemoryHttpResponse productCheckResponse = null + ) where TResponse : class, IElasticsearchResponse, new() + { + productCheckResponse ??= _productCheckResponse; + productCheckResponse.Headers.TryGetValue("X-elastic-product", out var productNames); + + requestData.MadeItToResponse = true; + + using var ms = requestData.MemoryStreamFactory.Create(productCheckResponse.ResponseBytes); + + return ResponseBuilder.ToResponse( + requestData, _exception, statusCode ?? productCheckResponse.StatusCode, null, ms, + RequestData.DefaultJsonMimeType, productNames?.FirstOrDefault()); + } + protected virtual void DisposeManagedResources() { } } } From df56be628822a575cb33f71c738e6e5052932f77 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:16:36 +0100 Subject: [PATCH 09/25] Add product checks --- .../Transport/Pipeline/IRequestPipeline.cs | 2 - .../Transport/Pipeline/PipelineException.cs | 14 +- .../Transport/Pipeline/ProductCheckStatus.cs | 17 + .../Transport/Pipeline/RequestPipeline.cs | 356 ++++++++++++++---- src/Elasticsearch.Net/Transport/Transport.cs | 121 +++--- 5 files changed, 367 insertions(+), 143 deletions(-) create mode 100644 src/Elasticsearch.Net/Transport/Pipeline/ProductCheckStatus.cs diff --git a/src/Elasticsearch.Net/Transport/Pipeline/IRequestPipeline.cs b/src/Elasticsearch.Net/Transport/Pipeline/IRequestPipeline.cs index f5ca84d3c87..c0a4fa4fb58 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/IRequestPipeline.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/IRequestPipeline.cs @@ -15,12 +15,10 @@ public interface IRequestPipeline : IDisposable bool FirstPoolUsageNeedsSniffing { get; } bool IsTakingTooLong { get; } int MaxRetries { get; } - int Retried { get; } bool SniffsOnConnectionFailure { get; } bool SniffsOnStaleCluster { get; } bool StaleClusterState { get; } - DateTime StartedOn { get; } TResponse CallElasticsearch(RequestData requestData) diff --git a/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs b/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs index 22ba53c8729..28c91559235 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs @@ -6,6 +6,16 @@ namespace Elasticsearch.Net { + public class InvalidProductException : Exception + { + public InvalidProductException() + : base(@"TODO: This client is designed to work with the official Elasticsearch product... + +Why are you seeing this error? +------------------------------ +TODO") { } + } + public class PipelineException : Exception { public PipelineException(PipelineFailure failure) @@ -18,9 +28,7 @@ public PipelineException(PipelineFailure failure, Exception innerException) public PipelineFailure FailureReason { get; } public bool Recoverable => - FailureReason == PipelineFailure.BadRequest - || FailureReason == PipelineFailure.BadResponse - || FailureReason == PipelineFailure.PingFailure; + FailureReason is PipelineFailure.BadRequest or PipelineFailure.BadResponse or PipelineFailure.PingFailure; public IElasticsearchResponse Response { get; internal set; } diff --git a/src/Elasticsearch.Net/Transport/Pipeline/ProductCheckStatus.cs b/src/Elasticsearch.Net/Transport/Pipeline/ProductCheckStatus.cs new file mode 100644 index 00000000000..e034f6c13de --- /dev/null +++ b/src/Elasticsearch.Net/Transport/Pipeline/ProductCheckStatus.cs @@ -0,0 +1,17 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elasticsearch.Net +{ + /// + /// Represents the status of the pre-flight product checks. + /// + public enum ProductCheckStatus + { + NotChecked, + ValidProduct, + InvalidProduct, + UndeterminedProduct + } +} diff --git a/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs b/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs index 04ff87207d6..97308b9d77d 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs @@ -18,8 +18,14 @@ namespace Elasticsearch.Net { public class RequestPipeline : IRequestPipeline { - private static readonly string NoNodesAttemptedMessage = - "No nodes were attempted, this can happen when a node predicate does not match any nodes"; + private const string NoNodesAttemptedMessage = "No nodes were attempted, this can happen when a node predicate does not match any nodes"; + + private static readonly Version MinVersion = new(6, 0, 0); + private static readonly Version Version7 = new(7, 0, 0); + private static readonly Version Version714 = new(7, 14, 0); + private const string ExpectedTagLine = "You Know, for Search"; + private const string ExpectedBuildFlavor = "default"; + private const string ExpectedProductName = "Elasticsearch"; private readonly IConnection _connection; private readonly IConnectionPool _connectionPool; @@ -61,7 +67,7 @@ public bool IsTakingTooLong var timeout = _settings.MaxRetryTimeout.GetValueOrDefault(RequestTimeout); var now = _dateTimeProvider.Now(); - //we apply a soft margin so that if a request timesout at 59 seconds when the maximum is 60 we also abort. + // we apply a soft margin so that if a request times out at 59 seconds when the maximum is 60 we also abort. var margin = timeout.TotalMilliseconds / 100.0 * 98; var marginTimeSpan = TimeSpan.FromMilliseconds(margin); var timespanCall = now - StartedOn; @@ -97,7 +103,8 @@ public bool StaleClusterState { get { - if (!SniffsOnStaleCluster) return false; + if (!SniffsOnStaleCluster) + return false; // ReSharper disable once PossibleInvalidOperationException // already checked by SniffsOnStaleCluster @@ -110,7 +117,7 @@ public bool StaleClusterState } } - public DateTime StartedOn { get; } + public DateTime StartedOn { get; private set; } private TimeSpan PingTimeout => RequestConfiguration?.PingTimeout @@ -152,53 +159,67 @@ ElasticsearchClientException exception public TResponse CallElasticsearch(RequestData requestData) where TResponse : class, IElasticsearchResponse, new() { - using (var audit = Audit(HealthyResponse, requestData.Node)) - using (var d = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData)) + using var audit = Audit(HealthyResponse, requestData.Node); + using var diagnostic = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData); + + audit.Path = requestData.PathAndQuery; + try { - audit.Path = requestData.PathAndQuery; - try - { - var response = _connection.Request(requestData); - d.EndState = response.ApiCall; - response.ApiCall.AuditTrail = AuditTrail; - audit.Stop(); - ThrowBadAuthPipelineExceptionWhenNeeded(response.ApiCall, response); - if (!response.ApiCall.Success) audit.Event = requestData.OnFailureAuditEvent; - return response; - } - catch (Exception e) - { - audit.Event = requestData.OnFailureAuditEvent; - audit.Exception = e; - throw; - } + var response = _connection.Request(requestData); + return PostCallElasticsearch(requestData, response, diagnostic, audit); + } + catch (Exception e) + { + audit.Event = requestData.OnFailureAuditEvent; + audit.Exception = e; + throw; } } public async Task CallElasticsearchAsync(RequestData requestData, CancellationToken cancellationToken) where TResponse : class, IElasticsearchResponse, new() { - using (var audit = Audit(HealthyResponse, requestData.Node)) - using (var d = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData)) + using var audit = Audit(HealthyResponse, requestData.Node); + using var diagnostic = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData); + + audit.Path = requestData.PathAndQuery; + try { - audit.Path = requestData.PathAndQuery; - try - { - var response = await _connection.RequestAsync(requestData, cancellationToken).ConfigureAwait(false); - d.EndState = response.ApiCall; - response.ApiCall.AuditTrail = AuditTrail; - audit.Stop(); - ThrowBadAuthPipelineExceptionWhenNeeded(response.ApiCall, response); - if (!response.ApiCall.Success) audit.Event = requestData.OnFailureAuditEvent; - return response; - } - catch (Exception e) + var response = await _connection.RequestAsync(requestData, cancellationToken).ConfigureAwait(false); + return PostCallElasticsearch(requestData, response, diagnostic, audit); + } + catch (Exception e) + { + audit.Event = requestData.OnFailureAuditEvent; + audit.Exception = e; + throw; + } + } + + public const string UndeterminedProductWarning = + "TODO: The client could not determine if the server is running the official Elasticsearch product."; + + private TResponse PostCallElasticsearch(RequestData requestData, TResponse response, Diagnostic diagnostic, Auditable audit) + where TResponse : class, IElasticsearchResponse, new() + { + // Add additional warning to debug information if the product could not be determined and may not be Elasticsearch + if (_connectionPool.ProductCheckStatus == ProductCheckStatus.UndeterminedProduct && response.ApiCall is ApiCallDetails callDetails) + { + Debug.WriteLine(UndeterminedProductWarning); + callDetails.BuildDebugInformationPrefix = sb => { - audit.Event = requestData.OnFailureAuditEvent; - audit.Exception = e; - throw; - } + sb.AppendLine("# Warnings:"); + sb.AppendLine($"- {UndeterminedProductWarning}"); + }; } + + diagnostic.EndState = response.ApiCall; + response.ApiCall.AuditTrail = AuditTrail; + audit.Stop(); + ThrowBadAuthPipelineExceptionWhenNeeded(response.ApiCall, response); + if (!response.ApiCall.Success) + audit.Event = requestData.OnFailureAuditEvent; + return response; } public ElasticsearchClientException CreateClientException( @@ -206,7 +227,8 @@ public ElasticsearchClientException CreateClientException( ) where TResponse : class, IElasticsearchResponse, new() { - if (callDetails?.Success ?? false) return null; + if (callDetails?.Success ?? false) + return null; var innerException = pipelineExceptions.HasAny() ? pipelineExceptions.AsAggregateOrFirst() : callDetails?.OriginalException; @@ -214,7 +236,7 @@ public ElasticsearchClientException CreateClientException( var resource = callDetails == null ? "unknown resource" : $"Status code {statusCode} from: {callDetails.HttpMethod} {callDetails.Uri.PathAndQuery}"; - + var exceptionMessage = innerException?.Message ?? "Request failed to execute"; var pipelineFailure = data.OnFailurePipelineFailure; @@ -241,7 +263,7 @@ public ElasticsearchClientException CreateClientException( exceptionMessage += ", failed over to all the known alive nodes before failing"; } } - + exceptionMessage += !exceptionMessage.EndsWith(".", StringComparison.Ordinal) ? $". Call: {resource}" : $" Call: {resource}"; if (response != null && response.TryGetServerErrorReason(out var reason)) @@ -259,24 +281,50 @@ public ElasticsearchClientException CreateClientException( public void FirstPoolUsage(SemaphoreSlim semaphore) { - if (!FirstPoolUsageNeedsSniffing) return; + // If sniffing has completed and the product check has run, we are done! + if (!FirstPoolUsageNeedsSniffing && _connectionPool.ProductCheckStatus != ProductCheckStatus.NotChecked) + return; - if (!semaphore.Wait(_settings.RequestTimeout)) + if (!semaphore.Wait(_settings.RequestTimeout.Add(_settings.RequestTimeout))) // Double the timeout to allow for product check delays { if (FirstPoolUsageNeedsSniffing) throw new PipelineException(PipelineFailure.CouldNotStartSniffOnStartup, null); - return; - } - - if (!FirstPoolUsageNeedsSniffing) - { - semaphore.Release(); + // We don't report a product check failure here to avoid breaking in unusual situations. + // Instead, we assume that subsequent requests will fail anyway. return; } try { + if (_connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked) + { + using (Audit(ProductCheckOnStartup)) + { + var nodes = _connectionPool.Nodes.ToArray(); // Isolated copy of nodes for the product check + + if (RequestConfiguration?.ForceNode is not null) + { + var node = new Node(RequestConfiguration.ForceNode); + ProductCheck(node); + } + else + { + // We determine the product from the first node which successfully responds. + for (var i = 0; i < nodes.Length && _connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked && !IsTakingTooLong; i++) + ProductCheck(nodes[i]); + } + + StartedOn = _dateTimeProvider.Now(); + } + } + + if (_connectionPool.ProductCheckStatus == ProductCheckStatus.InvalidProduct) + throw new InvalidProductException(); + + if (!FirstPoolUsageNeedsSniffing) + return; + using (Audit(SniffOnStartup)) { Sniff(); @@ -291,30 +339,58 @@ public void FirstPoolUsage(SemaphoreSlim semaphore) public async Task FirstPoolUsageAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken) { - if (!FirstPoolUsageNeedsSniffing) return; + // If sniffing has completed and the product check has run, we are done! + if (!FirstPoolUsageNeedsSniffing && _connectionPool.ProductCheckStatus != ProductCheckStatus.NotChecked) + return; // TODO cancellationToken could throw here and will bubble out as OperationCancelledException // everywhere else it would bubble out wrapped in a `UnexpectedElasticsearchClientException` - var success = await semaphore.WaitAsync(_settings.RequestTimeout, cancellationToken).ConfigureAwait(false); + var success = await semaphore.WaitAsync(_settings.RequestTimeout.Add(_settings.RequestTimeout), cancellationToken).ConfigureAwait(false); + if (!success) { if (FirstPoolUsageNeedsSniffing) throw new PipelineException(PipelineFailure.CouldNotStartSniffOnStartup, null); + // We don't report a product check failure here to avoid breaking in unusual situations. + // Instead, we assume that subsequent requests will fail anyway. return; } - if (!FirstPoolUsageNeedsSniffing) - { - semaphore.Release(); - return; - } try { - using (Audit(SniffOnStartup)) + if (_connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked) { - await SniffAsync(cancellationToken).ConfigureAwait(false); - _connectionPool.SniffedOnStartup = true; + using (Audit(ProductCheckOnStartup)) + { + var nodes = _connectionPool.Nodes.ToArray(); // Isolated copy of nodes for the product check + + if (RequestConfiguration?.ForceNode is not null) + { + var node = new Node(RequestConfiguration.ForceNode); + await ProductCheckAsync(node, cancellationToken).ConfigureAwait(false); + } + else + { + // We determine the product from the first node which successfully responds. + for (var i = 0; i < nodes.Length && _connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked && !IsTakingTooLong; i++) + await ProductCheckAsync(nodes[i], cancellationToken).ConfigureAwait(false); + } + + StartedOn = _dateTimeProvider.Now(); + } + } + + if (_connectionPool.ProductCheckStatus == ProductCheckStatus.InvalidProduct) + throw new InvalidProductException(); + + if (FirstPoolUsageNeedsSniffing) + { + using (Audit(SniffOnStartup)) + { + await SniffAsync(cancellationToken).ConfigureAwait(false); + _connectionPool.SniffedOnStartup = true; + } } } finally @@ -342,36 +418,41 @@ public IEnumerable NextNode() } //This for loop allows to break out of the view state machine if we need to - //force a refresh (after reseeding connectionpool). We have a hardcoded limit of only + //force a refresh (after reseeding connection pool). We have a hardcoded limit of only //allowing 100 of these refreshes per call var refreshed = false; for (var i = 0; i < 100; i++) { - if (DepletedRetries) yield break; + if (DepletedRetries) + yield break; foreach (var node in _connectionPool .CreateView(LazyAuditable) .TakeWhile(node => !DepletedRetries)) { - if (!_settings.NodePredicate(node)) continue; + if (!_settings.NodePredicate(node)) + continue; yield return node; - if (!Refresh) continue; + if (!Refresh) + continue; Refresh = false; refreshed = true; break; } //unless a refresh was requested we will not iterate over more then a single view. - //keep in mind refreshes are also still bound to overall maxretry count/timeout. - if (!refreshed) break; + //keep in mind refreshes are also still bound to overall max retry count/timeout. + if (!refreshed) + break; } } public void Ping(Node node) { - if (PingDisabled(node)) return; + if (PingDisabled(node)) + return; var pingData = CreatePingRequestData(node); using (var audit = Audit(PingSuccess, node)) @@ -400,7 +481,8 @@ public void Ping(Node node) public async Task PingAsync(Node node, CancellationToken cancellationToken) { - if (PingDisabled(node)) return; + if (PingDisabled(node)) + return; var pingData = CreatePingRequestData(node); using (var audit = Audit(PingSuccess, node)) @@ -427,6 +509,96 @@ public async Task PingAsync(Node node, CancellationToken cancellationToken) } } + internal void ProductCheck(Node node) + { + // We don't throw an exception on failure here since we don't want this new check to break consumers on upgrade. + + var requestData = CreateRootPathRequestData(node); + using var audit = Audit(ProductCheckSuccess, node); + + try + { + audit.Path = requestData.PathAndQuery; + var response = _connection.Request(requestData); + var succeeded = ApplyProductCheckRules(response); + audit.Stop(); + + if (!succeeded) + audit.Event = ProductCheckFailure; + } + catch (Exception e) + { + audit.Event = ProductCheckFailure; + audit.Exception = e; + } + } + + internal async Task ProductCheckAsync(Node node, CancellationToken cancellationToken) + { + // We don't throw an exception on failure here since we don't want this new check to break consumers on upgrade. + + var requestData = CreateRootPathRequestData(node); + using var audit = Audit(ProductCheckSuccess, node); + + try + { + audit.Path = requestData.PathAndQuery; + var response = await _connection.RequestAsync(requestData, cancellationToken).ConfigureAwait(false); + var succeeded = ApplyProductCheckRules(response); + audit.Stop(); + + if (!succeeded) + audit.Event = ProductCheckFailure; + } + catch (Exception e) + { + audit.Event = ProductCheckFailure; + audit.Exception = e; + } + } + + private bool ApplyProductCheckRules(RootResponse response) + { + if (response.HttpStatusCode.HasValue && (response.HttpStatusCode.Value == 401 || response.HttpStatusCode.Value == 403)) + { + // The call to the root path requires monitor permissions. If the current use lacks those, we cannot perform product validation. + _connectionPool.ProductCheckStatus = ProductCheckStatus.UndeterminedProduct; + return true; + } + + if (!response.Success) return false; + + // Start by assuming the product is valid + _connectionPool.ProductCheckStatus = ProductCheckStatus.ValidProduct; + + // We expect to have a version number from the build version. + // If we don't, the product is not Elasticsearch + if (string.IsNullOrEmpty(response.Version?.Number)) + { + _connectionPool.ProductCheckStatus = ProductCheckStatus.InvalidProduct; + } + else + { + var versionNumber = response.Version.Number; + var indexOfSuffix = versionNumber.IndexOf("-", StringComparison.Ordinal); + + if (indexOfSuffix > 0) + versionNumber = versionNumber.Substring(0, indexOfSuffix); + + var version = new Version(versionNumber); + + if (version < MinVersion || + version < Version7 && !ExpectedTagLine.Equals(response.Tagline) || + version >= Version7 && version < Version714 && (!ExpectedBuildFlavor.Equals(response.Version?.BuildFlavor, StringComparison.Ordinal) || !ExpectedTagLine.Equals(response.Tagline, StringComparison.Ordinal)) || + version >= Version714 && !ExpectedProductName.Equals(response.ApiCall.ProductName, StringComparison.Ordinal)) + { + _connectionPool.ProductCheckStatus = ProductCheckStatus.InvalidProduct; + } + } + + return true; + } + public void Sniff() { var exceptions = new List(); @@ -435,7 +607,7 @@ public void Sniff() var requestData = CreateSniffRequestData(node); using (var audit = Audit(SniffSuccess, node)) using (var d = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.Sniff, requestData)) - using(DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.Sniff, requestData)) + using (DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.Sniff, requestData)) { try { @@ -503,7 +675,8 @@ public async Task SniffAsync(CancellationToken cancellationToken) public void SniffOnConnectionFailure() { - if (!SniffsOnConnectionFailure) return; + if (!SniffsOnConnectionFailure) + return; using (Audit(SniffOnFail)) Sniff(); @@ -511,7 +684,8 @@ public void SniffOnConnectionFailure() public async Task SniffOnConnectionFailureAsync(CancellationToken cancellationToken) { - if (!SniffsOnConnectionFailure) return; + if (!SniffsOnConnectionFailure) + return; using (Audit(SniffOnFail)) await SniffAsync(cancellationToken).ConfigureAwait(false); @@ -519,7 +693,8 @@ public async Task SniffOnConnectionFailureAsync(CancellationToken cancellationTo public void SniffOnStaleCluster() { - if (!StaleClusterState) return; + if (!StaleClusterState) + return; using (Audit(AuditEvent.SniffOnStaleCluster)) { @@ -530,7 +705,8 @@ public void SniffOnStaleCluster() public async Task SniffOnStaleClusterAsync(CancellationToken cancellationToken) { - if (!StaleClusterState) return; + if (!StaleClusterState) + return; using (Audit(AuditEvent.SniffOnStaleCluster)) { @@ -558,7 +734,6 @@ private bool PingDisabled(Node node) => private RequestData CreatePingRequestData(Node node) { - var requestOverrides = new RequestConfiguration { PingTimeout = PingTimeout, @@ -575,6 +750,21 @@ private RequestData CreatePingRequestData(Node node) return data; } + private RequestData CreateRootPathRequestData(Node node) + { + var requestOverrides = new RequestConfiguration + { + BasicAuthenticationCredentials = _settings.BasicAuthenticationCredentials, + ApiKeyAuthenticationCredentials = _settings.ApiKeyAuthenticationCredentials, + EnableHttpPipelining = RequestConfiguration?.EnableHttpPipelining ?? _settings.HttpPipeliningEnabled, + ForceNode = RequestConfiguration?.ForceNode + }; + + IRequestParameters requestParameters = new RootNodeInfoRequestParameters { RequestConfiguration = requestOverrides }; + + return new RequestData(HttpMethod.GET, string.Empty, null, _settings, requestParameters, _memoryStreamFactory) { Node = node }; + } + private static void ThrowBadAuthPipelineExceptionWhenNeeded(IApiCallDetails details, IElasticsearchResponse response = null) { if (details?.HttpStatusCode == 401) @@ -587,14 +777,14 @@ private static void ThrowBadAuthPipelineExceptionWhenNeeded(IApiCallDetails deta private void LazyAuditable(AuditEvent e, Node n) { - using (new Auditable(e, AuditTrail, _dateTimeProvider, n)) { } + using (new Auditable(e, AuditTrail, _dateTimeProvider, n)) + { } } - private RequestData CreateSniffRequestData(Node node) => - new RequestData(HttpMethod.GET, SniffPath, null, _settings, SniffParameters, _memoryStreamFactory) - { - Node = node - }; + private RequestData CreateSniffRequestData(Node node) => new(HttpMethod.GET, SniffPath, null, _settings, SniffParameters, _memoryStreamFactory) + { + Node = node + }; protected virtual void Dispose() { } } diff --git a/src/Elasticsearch.Net/Transport/Transport.cs b/src/Elasticsearch.Net/Transport/Transport.cs index 08573d2904f..06cd2b2947b 100644 --- a/src/Elasticsearch.Net/Transport/Transport.cs +++ b/src/Elasticsearch.Net/Transport/Transport.cs @@ -58,15 +58,25 @@ IMemoryStreamFactory memoryStreamFactory public TResponse Request(HttpMethod method, string path, PostData data = null, IRequestParameters requestParameters = null) where TResponse : class, IElasticsearchResponse, new() { - using (var pipeline = PipelineProvider.Create(Settings, DateTimeProvider, MemoryStreamFactory, requestParameters)) - { - pipeline.FirstPoolUsage(Settings.BootstrapLock); + using var pipeline = PipelineProvider.Create(Settings, DateTimeProvider, MemoryStreamFactory, requestParameters); + + pipeline.FirstPoolUsage(Settings.BootstrapLock); - var requestData = new RequestData(method, path, data, Settings, requestParameters, MemoryStreamFactory); - Settings.OnRequestDataCreated?.Invoke(requestData); - TResponse response = null; + var requestData = new RequestData(method, path, data, Settings, requestParameters, MemoryStreamFactory); + Settings.OnRequestDataCreated?.Invoke(requestData); + TResponse response = null; - var seenExceptions = new List(); + var seenExceptions = new List(); + + if (pipeline.IsTakingTooLong) + { + // If the first pool usage has timed out, report this. + seenExceptions.Add(new PipelineException(PipelineFailure.MaxTimeoutReached, new TimeoutException( + "The configured timeout expired before the Elasticsearch call could be made." + + " The most likely cause is that some nodes took a long time to respond when checking the product version and/or sniffing."))); + } + else + { foreach (var node in pipeline.NextNode()) { requestData.Node = node; @@ -99,13 +109,15 @@ public TResponse Request(HttpMethod method, string path, PostData dat AuditTrail = pipeline.AuditTrail }; } - if (response == null || !response.ApiCall.SuccessOrKnownError) continue; + if (response == null || !response.ApiCall.SuccessOrKnownError) + continue; pipeline.MarkAlive(node); break; } - return FinalizeResponse(requestData, pipeline, seenExceptions, response); } + + return FinalizeResponse(requestData, pipeline, seenExceptions, response); } public async Task RequestAsync(HttpMethod method, string path, CancellationToken cancellationToken, @@ -113,62 +125,61 @@ public async Task RequestAsync(HttpMethod method, string p ) where TResponse : class, IElasticsearchResponse, new() { - using (var pipeline = PipelineProvider.Create(Settings, DateTimeProvider, MemoryStreamFactory, requestParameters)) - { - await pipeline.FirstPoolUsageAsync(Settings.BootstrapLock, cancellationToken).ConfigureAwait(false); + using var pipeline = PipelineProvider.Create(Settings, DateTimeProvider, MemoryStreamFactory, requestParameters); - var requestData = new RequestData(method, path, data, Settings, requestParameters, MemoryStreamFactory); - Settings.OnRequestDataCreated?.Invoke(requestData); - TResponse response = null; + await pipeline.FirstPoolUsageAsync(Settings.BootstrapLock, cancellationToken).ConfigureAwait(false); - var seenExceptions = new List(); - foreach (var node in pipeline.NextNode()) + var requestData = new RequestData(method, path, data, Settings, requestParameters, MemoryStreamFactory); + Settings.OnRequestDataCreated?.Invoke(requestData); + TResponse response = null; + + var seenExceptions = new List(); + foreach (var node in pipeline.NextNode()) + { + requestData.Node = node; + try { - requestData.Node = node; - try - { - await pipeline.SniffOnStaleClusterAsync(cancellationToken).ConfigureAwait(false); - await PingAsync(pipeline, node, cancellationToken).ConfigureAwait(false); - response = await pipeline.CallElasticsearchAsync(requestData, cancellationToken).ConfigureAwait(false); - if (!response.ApiCall.SuccessOrKnownError) - { - pipeline.MarkDead(node); - await pipeline.SniffOnConnectionFailureAsync(cancellationToken).ConfigureAwait(false); - } - } - catch (PipelineException pipelineException) when (!pipelineException.Recoverable) - { - HandlePipelineException(ref response, pipelineException, pipeline, node, seenExceptions); - break; - } - catch (PipelineException pipelineException) - { - HandlePipelineException(ref response, pipelineException, pipeline, node, seenExceptions); - } - catch (Exception killerException) + await pipeline.SniffOnStaleClusterAsync(cancellationToken).ConfigureAwait(false); + await PingAsync(pipeline, node, cancellationToken).ConfigureAwait(false); + response = await pipeline.CallElasticsearchAsync(requestData, cancellationToken).ConfigureAwait(false); + if (!response.ApiCall.SuccessOrKnownError) { - if (killerException is OperationCanceledException && cancellationToken.IsCancellationRequested) - pipeline.AuditCancellationRequested(); - - throw new UnexpectedElasticsearchClientException(killerException, seenExceptions) - { - Request = requestData, - Response = response?.ApiCall, - AuditTrail = pipeline.AuditTrail - }; + pipeline.MarkDead(node); + await pipeline.SniffOnConnectionFailureAsync(cancellationToken).ConfigureAwait(false); } - if (cancellationToken.IsCancellationRequested) - { + } + catch (PipelineException pipelineException) when (!pipelineException.Recoverable) + { + HandlePipelineException(ref response, pipelineException, pipeline, node, seenExceptions); + break; + } + catch (PipelineException pipelineException) + { + HandlePipelineException(ref response, pipelineException, pipeline, node, seenExceptions); + } + catch (Exception killerException) + { + if (killerException is OperationCanceledException && cancellationToken.IsCancellationRequested) pipeline.AuditCancellationRequested(); - break; - } - if (response == null || !response.ApiCall.SuccessOrKnownError) continue; - pipeline.MarkAlive(node); + throw new UnexpectedElasticsearchClientException(killerException, seenExceptions) + { + Request = requestData, + Response = response?.ApiCall, + AuditTrail = pipeline.AuditTrail + }; + } + if (cancellationToken.IsCancellationRequested) + { + pipeline.AuditCancellationRequested(); break; } - return FinalizeResponse(requestData, pipeline, seenExceptions, response); + if (response == null || !response.ApiCall.SuccessOrKnownError) continue; + + pipeline.MarkAlive(node); + break; } + return FinalizeResponse(requestData, pipeline, seenExceptions, response); } private static void HandlePipelineException( From e609fb338b4355acb8a4d189dfa7d7deab93eab9 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:16:56 +0100 Subject: [PATCH 10/25] Support product checking on virtual cluster --- .../Audit/Auditor.cs | 73 +++++++++++++------ .../Rules/ProductCheckRule.cs | 33 +++++++++ .../VirtualCluster.cs | 24 ++++-- .../VirtualClusterConnection.cs | 58 ++++++++++----- .../VirtualClusterWith.cs | 18 ++--- 5 files changed, 147 insertions(+), 59 deletions(-) create mode 100644 src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs diff --git a/src/Elasticsearch.Net.VirtualizedCluster/Audit/Auditor.cs b/src/Elasticsearch.Net.VirtualizedCluster/Audit/Auditor.cs index 0e277644986..9d5237a9aee 100644 --- a/src/Elasticsearch.Net.VirtualizedCluster/Audit/Auditor.cs +++ b/src/Elasticsearch.Net.VirtualizedCluster/Audit/Auditor.cs @@ -53,16 +53,17 @@ public void ChangeTime(Func selector) public async Task TraceStartup(ClientCall callTrace = null) { //synchronous code path - _cluster = _cluster ?? Cluster(); + _cluster ??= Cluster(); if (!StartedUp) AssertPoolBeforeStartup?.Invoke(_cluster.ConnectionPool); AssertPoolBeforeCall?.Invoke(_cluster.ConnectionPool); + // ReSharper disable once MethodHasAsyncOverload Response = _cluster.ClientCall(callTrace?.RequestOverrides); AuditTrail = Response.ApiCall.AuditTrail; if (!StartedUp) AssertPoolAfterStartup?.Invoke(_cluster.ConnectionPool); AssertPoolAfterCall?.Invoke(_cluster.ConnectionPool); //async code path - _clusterAsync = _clusterAsync ?? Cluster(); + _clusterAsync ??= Cluster(); if (!StartedUp) AssertPoolBeforeStartup?.Invoke(_clusterAsync.ConnectionPool); AssertPoolBeforeCall?.Invoke(_clusterAsync.ConnectionPool); ResponseAsync = await _clusterAsync.ClientCallAsync(callTrace?.RequestOverrides).ConfigureAwait(false); @@ -71,11 +72,17 @@ public async Task TraceStartup(ClientCall callTrace = null) AssertPoolAfterCall?.Invoke(_clusterAsync.ConnectionPool); return new Auditor(_cluster, _clusterAsync); } - + public async Task TraceCall(ClientCall callTrace, int nthCall = 0) { await TraceStartup(callTrace).ConfigureAwait(false); - return AssertAuditTrails(callTrace, nthCall); + return AssertAuditTrails(callTrace, nthCall, true); + } + + public async Task TraceCall(bool skipProductCheck, ClientCall callTrace, int nthCall = 0) + { + await TraceStartup(callTrace).ConfigureAwait(false); + return AssertAuditTrails(callTrace, nthCall, skipProductCheck); } #pragma warning disable 1998 // Async method lacks 'await' operators and will run synchronously @@ -83,7 +90,7 @@ private async Task TraceException(ClientCall callTrace, Action(ClientCall callTrace, Action callAsync = async () => ResponseAsync = await _clusterAsync.ClientCallAsync(callTrace?.RequestOverrides).ConfigureAwait(false); exception = await TryCallAsync(callAsync, assert).ConfigureAwait(false); @@ -121,7 +128,7 @@ public async Task TraceUnexpectedElasticsearchException(ClientCall call public async Task TraceElasticsearchExceptionOnResponse(ClientCall callTrace, Action assert) #pragma warning restore 1998 { - _cluster = _cluster ?? Cluster(); + _cluster ??= Cluster(); _cluster.ClientThrows(false); AssertPoolBeforeCall?.Invoke(_cluster.ConnectionPool); @@ -130,14 +137,15 @@ public async Task TraceElasticsearchExceptionOnResponse(ClientCall call if (Response.ApiCall.Success) throw new Exception("Expected call to not be valid"); - var exception = Response.ApiCall.OriginalException as ElasticsearchClientException; - if (exception == null) throw new Exception("OriginalException on response is not expected ElasticsearchClientException"); + if (Response.ApiCall.OriginalException is not ElasticsearchClientException exception) + throw new Exception("OriginalException on response is not expected ElasticsearchClientException"); + assert(exception); AuditTrail = exception.AuditTrail; AssertPoolAfterCall?.Invoke(_cluster.ConnectionPool); - _clusterAsync = _clusterAsync ?? Cluster(); + _clusterAsync ??= Cluster(); _clusterAsync.ClientThrows(false); Func callAsync = async () => { ResponseAsync = await _clusterAsync.ClientCallAsync(callTrace?.RequestOverrides).ConfigureAwait(false); }; await callAsync().ConfigureAwait(false); @@ -157,30 +165,38 @@ public async Task TraceElasticsearchExceptionOnResponse(ClientCall call public async Task TraceUnexpectedException(ClientCall callTrace, Action assert) #pragma warning restore 1998 { - _cluster = _cluster ?? Cluster(); + _cluster ??= Cluster(); AssertPoolBeforeCall?.Invoke(_cluster.ConnectionPool); - Action call = () => Response = _cluster.ClientCall(callTrace?.RequestOverrides); - var exception = TryCall(call, assert); + + var exception = TryCall(Call, assert); assert(exception); AuditTrail = exception.AuditTrail; AssertPoolAfterCall?.Invoke(_cluster.ConnectionPool); - _clusterAsync = _clusterAsync ?? Cluster(); - Func callAsync = async () => ResponseAsync = await _clusterAsync.ClientCallAsync(callTrace?.RequestOverrides).ConfigureAwait(false); - exception = await TryCallAsync(callAsync, assert).ConfigureAwait(false); + _clusterAsync ??= Cluster(); + + exception = await TryCallAsync(CallAsync, assert).ConfigureAwait(false); assert(exception); AsyncAuditTrail = exception.AuditTrail; AssertPoolAfterCall?.Invoke(_clusterAsync.ConnectionPool); + return new Auditor(_cluster, _clusterAsync); + + void Call() => Response = _cluster.ClientCall(callTrace?.RequestOverrides); + async Task CallAsync() => ResponseAsync = await _clusterAsync.ClientCallAsync(callTrace?.RequestOverrides).ConfigureAwait(false); } - private Auditor AssertAuditTrails(ClientCall callTrace, int nthCall) + private Auditor AssertAuditTrails(ClientCall callTrace, int nthCall, bool skipProductCheck) { var nl = Environment.NewLine; - if (AuditTrail.Count != AsyncAuditTrail.Count) + + if (skipProductCheck) + AuditTrail.RemoveAll(a => a.Event is AuditEvent.ProductCheckOnStartup or AuditEvent.ProductCheckSuccess or AuditEvent.ProductCheckFailure); + + if (AuditTrail.Count(Predicate) != AsyncAuditTrail.Count(Predicate)) throw new Exception($"{nthCall} has a mismatch between sync and async. {nl}async:{AuditTrail}{nl}sync:{AsyncAuditTrail}"); AssertTrailOnResponse(callTrace, AuditTrail, true, nthCall); @@ -192,8 +208,13 @@ private Auditor AssertAuditTrails(ClientCall callTrace, int nthCall) callTrace?.AssertPoolAfterCall?.Invoke(_cluster.ConnectionPool); callTrace?.AssertPoolAfterCall?.Invoke(_clusterAsync.ConnectionPool); return new Auditor(_cluster, _clusterAsync); - } + // These happen one time only so should not be counted when comparing equality of audit trails + static bool Predicate(Net.Audit auditEvent) => + auditEvent.Event != AuditEvent.ProductCheckOnStartup && + auditEvent.Event != AuditEvent.ProductCheckFailure && + auditEvent.Event != AuditEvent.ProductCheckSuccess; + } public void VisualizeCalls(int numberOfCalls) { @@ -218,14 +239,24 @@ private static string AuditTrailToString(List auditTrai return actualAuditTrail; } + public async Task TraceCalls(params ClientCall[] audits) { var auditor = this; - foreach (var a in audits.Select((a, i) => new { a, i })) auditor = await auditor.TraceCall(a.a, a.i).ConfigureAwait(false); + foreach (var a in audits.Select((a, i) => new { a, i })) + auditor = await auditor.TraceCall(a.a, a.i).ConfigureAwait(false); + return auditor; + } + + public async Task TraceCalls(bool skipProductCheck, params ClientCall[] audits) + { + var auditor = this; + foreach (var a in audits.Select((a, i) => new { a, i })) + auditor = await auditor.TraceCall(skipProductCheck, a.a, a.i).ConfigureAwait(false); return auditor; } - private static void AssertTrailOnResponse(ClientCall callTrace, List auditTrail, bool sync, int nthCall) + private static void AssertTrailOnResponse(ClientCall callTrace, IReadOnlyCollection auditTrail, bool sync, int nthCall) { var typeOfTrail = (sync ? "synchronous" : "asynchronous") + " audit trail"; var nthClientCall = (nthCall + 1).ToOrdinal(); diff --git a/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs b/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs new file mode 100644 index 00000000000..3b28f45d53f --- /dev/null +++ b/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs @@ -0,0 +1,33 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; + +namespace Elasticsearch.Net.VirtualizedCluster.Rules +{ + public class ProductCheckRule : RuleBase + { + private IRule Self => this; + + public ProductCheckRule Fails(RuleOption times, RuleOption errorState = null) + { + Self.Times = times; + Self.Succeeds = false; + Self.Return = errorState; + return this; + } + + public ProductCheckRule Succeeds(RuleOption times, int? validResponseCode = 200) + { + Self.Times = times; + Self.Succeeds = true; + Self.Return = validResponseCode; + return this; + } + + public ProductCheckRule SucceedAlways(int? validResponseCode = 200) => Succeeds(TimesHelper.Always, validResponseCode); + + public ProductCheckRule FailAlways(RuleOption errorState = null) => Fails(TimesHelper.Always, errorState); + } +} diff --git a/src/Elasticsearch.Net.VirtualizedCluster/VirtualCluster.cs b/src/Elasticsearch.Net.VirtualizedCluster/VirtualCluster.cs index 08ec7e63cc7..0599cc9f6de 100644 --- a/src/Elasticsearch.Net.VirtualizedCluster/VirtualCluster.cs +++ b/src/Elasticsearch.Net.VirtualizedCluster/VirtualCluster.cs @@ -15,15 +15,23 @@ public class VirtualCluster { private readonly List _nodes; - public VirtualCluster(IEnumerable nodes) => _nodes = nodes.ToList(); + public VirtualCluster(IEnumerable nodes, bool productCheckSucceeds = true) + { + _nodes = nodes.ToList(); + + if (productCheckSucceeds) + ProductCheckRules.Add(new ProductCheckRule().SucceedAlways()); + } - public List ClientCallRules { get; } = new List(); - public TestableDateTimeProvider DateTimeProvider { get; } = new TestableDateTimeProvider(); + public List ClientCallRules { get; } = new(); + public TestableDateTimeProvider DateTimeProvider { get; } = new(); public IReadOnlyList Nodes => _nodes; - public List PingingRules { get; } = new List(); - public List SniffingRules { get; } = new List(); + public List PingingRules { get; } = new(); + public List SniffingRules { get; } = new(); + public List ProductCheckRules { get; } = new(); + internal string PublishAddressOverride { get; private set; } internal bool SniffShouldReturnFqnd { get; private set; } @@ -85,6 +93,12 @@ public VirtualCluster Sniff(Func selector) SniffingRules.Add(selector(new SniffRule())); return this; } + + public VirtualCluster ProductCheck(Func selector) + { + ProductCheckRules.Add(selector(new ProductCheckRule())); + return this; + } public VirtualCluster ClientCalls(Func selector) { diff --git a/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterConnection.cs b/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterConnection.cs index d8c5d3a1a94..2b210614729 100644 --- a/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterConnection.cs +++ b/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterConnection.cs @@ -6,16 +6,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; -#if DOTNETCORE -using TheException = System.Net.Http.HttpRequestException; -#else -using TheException = System.Net.WebException; -#endif using System.Threading; using System.Threading.Tasks; using Elasticsearch.Net.VirtualizedCluster.MockResponses; using Elasticsearch.Net.VirtualizedCluster.Providers; using Elasticsearch.Net.VirtualizedCluster.Rules; +#if DOTNETCORE +using TheException = System.Net.Http.HttpRequestException; +#else +using TheException = System.Net.WebException; +#endif namespace Elasticsearch.Net.VirtualizedCluster { @@ -31,7 +31,7 @@ namespace Elasticsearch.Net.VirtualizedCluster /// public class VirtualClusterConnection : InMemoryConnection { - private static readonly object Lock = new object(); + private static readonly object Lock = new(); private static byte[] _defaultResponseBytes; @@ -101,6 +101,9 @@ public bool IsPingRequest(RequestData requestData) => requestData.Method == HttpMethod.HEAD && (requestData.PathAndQuery == string.Empty || requestData.PathAndQuery.StartsWith("?")); + public bool IsProductCheckRequest(RequestData requestData) => + requestData.Uri.AbsolutePath.Equals("/", StringComparison.Ordinal) && requestData.Method == HttpMethod.GET; + public override TResponse Request(RequestData requestData) { if (!_calls.ContainsKey(requestData.Uri.Port)) @@ -117,8 +120,8 @@ public override TResponse Request(RequestData requestData) nameof(VirtualCluster.Sniff), _cluster.SniffingRules, requestData.RequestTimeout, - (r) => UpdateCluster(r.NewClusterState), - (r) => SniffResponseBytes.Create(_cluster.Nodes, _cluster.ElasticsearchVersion,_cluster.PublishAddressOverride, _cluster.SniffShouldReturnFqnd) + r => UpdateCluster(r.NewClusterState), + _ => SniffResponseBytes.Create(_cluster.Nodes, _cluster.ElasticsearchVersion,_cluster.PublishAddressOverride, _cluster.SniffShouldReturnFqnd) ); } if (IsPingRequest(requestData)) @@ -129,8 +132,20 @@ public override TResponse Request(RequestData requestData) nameof(VirtualCluster.Ping), _cluster.PingingRules, requestData.PingTimeout, - (r) => { }, - (r) => null //HEAD request + _ => { }, + _ => null //HEAD request + ); + } + if (IsProductCheckRequest(requestData)) + { + _ = Interlocked.Increment(ref state.ProductChecked); + return HandleRules( + requestData, + nameof(VirtualCluster.ProductCheck), + _cluster.ProductCheckRules, + requestData.RequestTimeout, + _ => { }, + _ => ValidProductCheckResponse().ResponseBytes ); } _ = Interlocked.Increment(ref state.Called); @@ -139,7 +154,7 @@ public override TResponse Request(RequestData requestData) nameof(VirtualCluster.ClientCalls), _cluster.ClientCallRules, requestData.RequestTimeout, - (r) => { }, + _ => { }, CallResponse ); } @@ -247,14 +262,17 @@ private TResponse Fail(RequestData requestData, TRule rule, Ru throw new TheException(); return ret.Match( - (e) => throw e, - (statusCode) => ReturnConnectionStatus(requestData, CallResponse(rule), + e => throw e, + statusCode => ReturnConnectionStatus(requestData, CallResponse(rule), //make sure we never return a valid status code in Fail responses because of a bad rule. - statusCode >= 200 && statusCode < 300 ? 502 : statusCode, rule.ReturnContentType) + statusCode is >= 200 and < 300 ? 502 : statusCode, rule.ReturnContentType) ); } - private TResponse Success(RequestData requestData, Action beforeReturn, Func successResponse, + private TResponse Success( + RequestData requestData, + Action beforeReturn, + Func successResponse, TRule rule ) where TResponse : class, IElasticsearchResponse, new() @@ -277,11 +295,10 @@ private static byte[] CallResponse(TRule rule) if (_defaultResponseBytes != null) return _defaultResponseBytes; var response = DefaultResponse; - using (var ms = RecyclableMemoryStreamFactory.Default.Create()) - { - LowLevelRequestResponseSerializer.Instance.Serialize(response, ms); - _defaultResponseBytes = ms.ToArray(); - } + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(response, ms); + _defaultResponseBytes = ms.ToArray(); return _defaultResponseBytes; } @@ -295,6 +312,7 @@ private class State public int Pinged; public int Sniffed; public int Successes; + public int ProductChecked; } } } diff --git a/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterWith.cs b/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterWith.cs index 4bd18496380..7d7dfae91e3 100644 --- a/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterWith.cs +++ b/src/Elasticsearch.Net.VirtualizedCluster/VirtualClusterWith.cs @@ -10,20 +10,12 @@ namespace Elasticsearch.Net.VirtualizedCluster { public static class VirtualClusterWith { - public static VirtualCluster Nodes(int numberOfNodes, int startFrom = 9200) => - new VirtualCluster( - Enumerable.Range(startFrom, numberOfNodes).Select(n => new Node(new Uri($"http://localhost:{n}"))) - ); + public static VirtualCluster Nodes(int numberOfNodes, int startFrom = 9200, bool productCheckAlwaysSucceeds = true) => + new (Enumerable.Range(startFrom, numberOfNodes).Select(n => new Node(new Uri($"http://localhost:{n}"))), productCheckAlwaysSucceeds); - public static VirtualCluster MasterOnlyNodes(int numberOfNodes, int startFrom = 9200) => - new VirtualCluster( - Enumerable.Range(startFrom, numberOfNodes) - .Select(n => new Node(new Uri($"http://localhost:{n}")) { HoldsData = false, MasterEligible = true }) - ); - - - public static VirtualCluster Nodes(IEnumerable nodes) => - new VirtualCluster(nodes); + public static VirtualCluster MasterOnlyNodes(int numberOfNodes, int startFrom = 9200, bool productCheckSucceeds = true) => + new (Enumerable.Range(startFrom, numberOfNodes).Select(n => new Node(new Uri($"http://localhost:{n}")) { HoldsData = false, MasterEligible = true }), productCheckSucceeds); + public static VirtualCluster Nodes(IEnumerable nodes, bool productCheckSucceeds = true) => new (nodes, productCheckSucceeds); } } From c0d652f08320fbedd72befcf382f75e6547d506f Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:31:11 +0100 Subject: [PATCH 11/25] Add product check doc and tests --- .../ProductCheckAtStartup.doc.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs new file mode 100644 index 00000000000..4acf68ed4e8 --- /dev/null +++ b/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs @@ -0,0 +1,102 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Threading.Tasks; +using Elastic.Elasticsearch.Xunit.XunitPlumbing; +using Elasticsearch.Net.VirtualizedCluster; +using Elasticsearch.Net.VirtualizedCluster.Audit; +using Elasticsearch.Net.VirtualizedCluster.Rules; +using static Elasticsearch.Net.AuditEvent; + +namespace Tests.ClientConcepts.ConnectionPooling.ProductChecking +{ + public class ProductCheckAtStartup + { + /**=== Product check on first usage + * + * Since 7.14.0, the client performs a required product check during the first call. + * This pre-flight product check allows us to establish the version of Elasticsearch we are communicating with. + * + * The product check requires one additional HTTP request to be sent to the server as part of the request pipeline. + * Once the product check succeeds, no further product check HTTP requests are sent as part of the pipeline. + */ + [U] public async Task ProductCheckPerformedOnlyOnFirstCallWhenSuccessful() + { + var audit = new Auditor(() => VirtualClusterWith + .Nodes(1) + .ClientCalls(r => r.SucceedAlways()) + .StaticConnectionPool() + .Settings(s => s.DisablePing()) + ); + + audit = await audit.TraceCalls(skipProductCheck: false, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckSuccess, 9200 }, // <1> as this is the first call, the product check is executed + { HealthyResponse, 9200 } // <2> following the product check, the actual request is sent + }, + new ClientCall() { + { HealthyResponse, 9200 } // <3> subsequent calls no longer perform product check + } + ); + } + + [U] + public async Task ProductCheckPerformedOnSecondCallWhenFirstCheckFails() + { + /** Here's an example with a single node cluster which fails for some reason during the first product check attempt. */ + var audit = new Auditor(() => VirtualClusterWith + .Nodes(1, productCheckAlwaysSucceeds: false) + .ProductCheck(r => r.Fails(TimesHelper.Once)) + .ProductCheck(r => r.SucceedAlways()) + .ClientCalls(r => r.SucceedAlways()) + .StaticConnectionPool() + .Settings(s => s.DisablePing()) + ); + + audit = await audit.TraceCalls(skipProductCheck: false, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckFailure, 9200 }, // <1> as this is the first call, the product check is executed, but fails + { HealthyResponse, 9200 } // <2> the actual request is still sent and succeeds + }, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckSuccess, 9200 }, // <3> as the previous product check failed, it runs on the second call + { HealthyResponse, 9200 } + }, + new ClientCall() { + { HealthyResponse, 9200 } // <4> subsequent calls no longer perform product check + } + ); + } + + [U] + public async Task ProductCheckAttemptsAllNodes() + { + /** Here's an example with a three node cluster which fails for some reason during the first and second product check attempts. */ + var audit = new Auditor(() => VirtualClusterWith + .Nodes(3, productCheckAlwaysSucceeds: false) + .ProductCheck(r => r.FailAlways()) + .ProductCheck(r => r.OnPort(9202).SucceedAlways()) + .ClientCalls(r => r.SucceedAlways()) + .StaticConnectionPool() + .Settings(s => s.DisablePing()) + ); + + audit = await audit.TraceCalls(skipProductCheck: false, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckFailure, 9200 }, // <1> this is the first call, the product check is executed, but fails on this node + { ProductCheckFailure, 9201 }, // <2> the next node is also tried and fails + { ProductCheckSuccess, 9202 }, // <3> the third node is tried, successfully responds and the product check succeeds + { HealthyResponse, 9200 } // <4> the actual request is sent and succeeds + }, + new ClientCall() { + { HealthyResponse, 9201 } // <5> subsequent calls no longer perform product check + } + ); + } + } +} From e24a97472c0b84ac79c93977952126cac68d0a20 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:32:05 +0100 Subject: [PATCH 12/25] Remove ping integration tests These no longer work using a custom path as the product check fails with a 404 and is assumed invalid. --- .../ConnectionPooling/Pinging/PingTests.cs | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 tests/Tests/ClientConcepts/ConnectionPooling/Pinging/PingTests.cs diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/PingTests.cs b/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/PingTests.cs deleted file mode 100644 index 1ffbd4fbd96..00000000000 --- a/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/PingTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System; -using Elastic.Elasticsearch.Xunit.XunitPlumbing; -using Elasticsearch.Net; -using FluentAssertions; -using Nest; -using Tests.ClientConcepts.Connection; -using Tests.Core.ManagedElasticsearch.Clusters; - - -namespace Tests.ClientConcepts.ConnectionPooling.Pinging -{ - public class PingTests : IClusterFixture - { - private readonly ReadOnlyCluster _cluster; - - public PingTests(ReadOnlyCluster cluster) => _cluster = cluster; - -#if DOTNETCORE - [I] - public void UsesRelativePathForPing() - { - var pool = new StaticConnectionPool(new[] { new Uri("http://localhost:9200/elasticsearch/") }); - var settings = new ConnectionSettings(pool, - new HttpConnectionTests.TestableHttpConnection(response => - { - response.RequestMessage.RequestUri.AbsolutePath.Should().StartWith("/elasticsearch/"); - })); - - var client = new ElasticClient(settings); - var healthResponse = client.Ping(); - } -#else - [I] - public void UsesRelativePathForPing() - { - var pool = new StaticConnectionPool(new[] { new Uri("http://localhost:9200/elasticsearch/") }); - var connection = new HttpWebRequestConnectionTests.TestableHttpWebRequestConnection(); - var settings = new ConnectionSettings(pool, connection); - - var client = new ElasticClient(settings); - var healthResponse = client.Ping(); - - connection.LastRequest.Address.AbsolutePath.Should().StartWith("/elasticsearch/"); - } -#endif - } -} - From ec410b65f42bc5f7e31796679f6c875b75c3e557 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:32:32 +0100 Subject: [PATCH 13/25] Add async version of concurrent first usage test --- .../BuildingBlocks/RequestPipelines.doc.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs index 03f338ed1bc..c46ead492b8 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs @@ -189,6 +189,77 @@ public void FirstUsageCheckConcurrentThreads() semaphoreSlim.CurrentCount.Should().Be(1); } + // hide + [U] public async Task FirstUsageCheckConcurrentThreadsAsync() + { + //hide + var response = new + { + cluster_name = "elasticsearch", + nodes = new + { + node1 = new + { + name = "Node Name 1", + transport_address = "127.0.0.1:9300", + host = "127.0.0.1", + ip = "127.0.01", + version = "5.0.0-alpha3", + build_hash = "e455fd0", + roles = new List(), + http = new + { + bound_address = new[] { "127.0.0.1:9200" } + }, + settings = new Dictionary + { + { "cluster.name", "elasticsearch" }, + { "node.name", "Node Name 1" } + } + } + } + }; + + //hide + var responseBody = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(response)); + + /** We can demonstrate this with the following example. First, let's configure + * a custom `IConnection` implementation that's simply going to return a known + * 200 response after one second + */ + var inMemoryConnection = new WaitingInMemoryConnection( + TimeSpan.FromSeconds(1), + responseBody); + + /** + * Next, we create a <> using our + * custom connection and a timeout for how long a request can take before the client + * times out + */ + var sniffingPipeline = CreatePipeline( + uris => new SniffingConnectionPool(uris), + connection: inMemoryConnection, + settingsSelector: s => s.RequestTimeout(TimeSpan.FromSeconds(2))); + + /**Now, with a `SemaphoreSlim` in place that allows only one thread to enter at a time, + * start three tasks that will initiate a sniff on startup. + * + * The first task will successfully sniff on startup with the remaining two waiting + * tasks exiting without exception. The `SemaphoreSlim` is also released, ready for + * when sniffing needs to take place again + */ + var semaphoreSlim = new SemaphoreSlim(1, 1); + + var task1 = sniffingPipeline.FirstPoolUsageAsync(semaphoreSlim, CancellationToken.None); + var task2 = sniffingPipeline.FirstPoolUsageAsync(semaphoreSlim, CancellationToken.None); + var task3 = sniffingPipeline.FirstPoolUsageAsync(semaphoreSlim, CancellationToken.None); + + var exception = await Record.ExceptionAsync(async () => await Task.WhenAll(task1, task2, task3)); + + exception.Should().BeNull(); + semaphoreSlim.CurrentCount.Should().Be(1); + } + /**==== Sniff on connection failure */ [U] public void SniffsOnConnectionFailure() From 4c6b527b80b400022aa02c1784a83bfc0af120a6 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:33:17 +0100 Subject: [PATCH 14/25] Add product check info to audit trail doc --- .../Troubleshooting/AuditTrail.doc.cs | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/tests/Tests/ClientConcepts/Troubleshooting/AuditTrail.doc.cs b/tests/Tests/ClientConcepts/Troubleshooting/AuditTrail.doc.cs index 4cf887a5c13..0eaf92d0b27 100644 --- a/tests/Tests/ClientConcepts/Troubleshooting/AuditTrail.doc.cs +++ b/tests/Tests/ClientConcepts/Troubleshooting/AuditTrail.doc.cs @@ -3,11 +3,6 @@ // See the LICENSE file in the project root for more information using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Elastic.Elasticsearch.Xunit.Sdk; using Elastic.Elasticsearch.Xunit.XunitPlumbing; using Elasticsearch.Net; using FluentAssertions; @@ -15,8 +10,6 @@ using Tests.Core.Client.Settings; using Tests.Core.ManagedElasticsearch.Clusters; using Tests.Domain; -using Tests.Framework; -using Xunit; namespace Tests.ClientConcepts.Troubleshooting { @@ -37,7 +30,7 @@ [I] public void AvailableOnResponse() { /** * We'll use a Sniffing connection pool here since it sniffs on startup and pings before - * first usage, so we can get an audit trail with a few events out + * first usage, so we can get an audit trail with a few events out. */ var pool = new SniffingConnectionPool(new []{ TestConnectionSettings.CreateUri() }); var connectionSettings = new ConnectionSettings(pool) @@ -48,7 +41,7 @@ [I] public void AvailableOnResponse() var client = new ElasticClient(connectionSettings); /** - * After issuing the following request + * After issuing the following request: */ var response = client.Search(s => s .MatchAll() @@ -60,10 +53,12 @@ [I] public void AvailableOnResponse() * .... * Valid NEST response built from a successful low level call on POST: /project/doc/_search * # Audit trail of this API call: - * - [1] SniffOnStartup: Took: 00:00:00.0360264 - * - [2] SniffSuccess: Node: http://localhost:9200/ Took: 00:00:00.0310228 - * - [3] PingSuccess: Node: http://127.0.0.1:9200/ Took: 00:00:00.0115074 - * - [4] HealthyResponse: Node: http://127.0.0.1:9200/ Took: 00:00:00.1477640 + * - [1] ProductCheckOnStartup: Took: 00:00:00.1014934 + * - [2] ProductCheckSuccess: Node: http://ipv4.fiddler:9200/ Took: 00:00:00.1002862 + * - [3] SniffOnStartup: Took: 00:00:00.0440780 + * - [4] SniffSuccess: Node: http://localhost:9200/ Took: 00:00:00.0257506 + * - [5] PingSuccess: Node: http://127.0.0.1:9200/ Took: 00:00:00.0115074 + * - [6] HealthyResponse: Node: http://127.0.0.1:9200/ Took: 00:00:00.1477640 * # Request: * * # Response: @@ -77,19 +72,20 @@ [I] public void AvailableOnResponse() /** * But can also be accessed manually: */ - response.ApiCall.AuditTrail.Count.Should().Be(4, "{0}", debug); - response.ApiCall.AuditTrail[0].Event.Should().Be(AuditEvent.SniffOnStartup, "{0}", debug); - response.ApiCall.AuditTrail[1].Event.Should().Be(AuditEvent.SniffSuccess, "{0}", debug); - response.ApiCall.AuditTrail[2].Event.Should().Be(AuditEvent.PingSuccess, "{0}", debug); - response.ApiCall.AuditTrail[3].Event.Should().Be(AuditEvent.HealthyResponse, "{0}", debug); + response.ApiCall.AuditTrail.Count.Should().Be(6, "{0}", debug); + response.ApiCall.AuditTrail[0].Event.Should().Be(AuditEvent.ProductCheckOnStartup, "{0}", debug); + response.ApiCall.AuditTrail[1].Event.Should().Be(AuditEvent.ProductCheckSuccess, "{0}", debug); + response.ApiCall.AuditTrail[2].Event.Should().Be(AuditEvent.SniffOnStartup, "{0}", debug); + response.ApiCall.AuditTrail[3].Event.Should().Be(AuditEvent.SniffSuccess, "{0}", debug); + response.ApiCall.AuditTrail[4].Event.Should().Be(AuditEvent.PingSuccess, "{0}", debug); + response.ApiCall.AuditTrail[5].Event.Should().Be(AuditEvent.HealthyResponse, "{0}", debug); /** * Each audit has a started and ended `DateTime` on it that will provide - * some understanding of how long it took + * some understanding of how long it took. */ response.ApiCall.AuditTrail .Should().OnlyContain(a => a.Ended - a.Started >= TimeSpan.Zero); - } } } From dfad328bd2ad099e7c0cb1113b1753ca5f569f7a Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:33:37 +0100 Subject: [PATCH 15/25] Add more product check tests --- .../Tests/ClientConcepts/ProductCheckTests.cs | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/Tests/ClientConcepts/ProductCheckTests.cs diff --git a/tests/Tests/ClientConcepts/ProductCheckTests.cs b/tests/Tests/ClientConcepts/ProductCheckTests.cs new file mode 100644 index 00000000000..efac177cd32 --- /dev/null +++ b/tests/Tests/ClientConcepts/ProductCheckTests.cs @@ -0,0 +1,267 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Generic; +using System.Net; +using Elastic.Elasticsearch.Xunit.XunitPlumbing; +using Elasticsearch.Net; +using FluentAssertions; +using Nest; + +namespace Tests.ClientConcepts +{ + public class ProductCheckTests + { + [U] public void MissingProductNameCausesException() + { + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.Headers.Clear(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void InvalidProductNameCausesException() + { + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.Headers.Clear(); + productCheckResponse.Headers.Add("X-elastic-product", new List{ "Something Unexpected" }); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void UnauthorizedStatusCodeFromRootPathDoesNotThrowException_WithExpectedDataOnApiCall() + { + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.StatusCode = (int)HttpStatusCode.Unauthorized; + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + var response = client.Cluster.Health(); + + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckOnStartup); + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckSuccess); + response.ApiCall.DebugInformation.Should().Contain(RequestPipeline.UndeterminedProductWarning); + } + + [U] public void ForbiddenStatusCodeFromRootPathDoesNotThrowException_WithExpectedDataOnApiCall() + { + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.StatusCode = (int)HttpStatusCode.Forbidden; + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + var response = client.Cluster.Health(); + + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckOnStartup); + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckSuccess); + response.ApiCall.DebugInformation.Should().Contain(RequestPipeline.UndeterminedProductWarning); + } + + [U] public void OldVersionsThrowException() + { + var responseJson = new + { + version = new + { + number = "5.9.999", + build_flavor = "default", + }, + tagline = "You Know, for Search" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void MissingTaglineOnVersionSixThrowsException() + { + var responseJson = new + { + version = new + { + number = "6.10.0", + build_flavor = "default" + } + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void InvalidTaglineOnVersionSixThrowsException() + { + var responseJson = new + { + version = new + { + number = "6.10.0", + build_flavor = "default" + }, + tagline = "unexpected" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void ExpectedTaglineOnVersionSixDoesNotThrowException_WithExpectedDataOnApiCall() + { + var responseJson = new + { + version = new + { + number = "6.8.0", + build_flavor = "default" + }, + tagline = "You Know, for Search" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + var response = client.Cluster.Health(); + + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckOnStartup); + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckSuccess); + } + + [U] public void MissingBuildFlavorOnVersionSevenThrowsException() + { + var responseJson = new + { + version = new + { + number = "7.13.0" + }, + tagline = "You Know, for Search" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void InvalidBuildFlavorMissingOnVersionSevenThrowsException() + { + var responseJson = new + { + version = new + { + number = "7.13.0", + build_flavor = "unexpected" + }, + tagline = "You Know, for Search" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + client.Invoking(y => y.Cluster.Health()) + .Should() + .Throw(); + } + + [U] public void ExpectedBuildFlavorOnVersionSixDoesNotThrowException_WithExpectedDataOnApiCall() + { + var responseJson = new + { + version = new + { + number = "7.13.0", + build_flavor = "default" + }, + tagline = "You Know, for Search" + }; + + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + + var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + productCheckResponse.ResponseBytes = ms.ToArray(); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + var response = client.Cluster.Health(); + + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckOnStartup); + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckSuccess); + } + } +} From 08d9645afef71a302128250c25e32444526eeaba Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:34:26 +0100 Subject: [PATCH 16/25] Further test cleanup and fixup --- .../Tests.Core/Client/FixedResponseClient.cs | 16 ++++++-- .../Exceptions/UnexpectedExceptions.doc.cs | 2 +- .../Failover/FallingOver.doc.cs | 12 +++--- .../MaxRetries/RespectsMaxRetry.doc.cs | 2 - .../Pinging/FirstUsage.doc.cs | 2 - .../DisableSniffPingPerRequest.doc.cs | 2 +- .../RequestTimeoutsOverrides.doc.cs | 7 ++-- .../RespectsMaxRetryOverrides.doc.cs | 3 -- .../LoggingWithOnRequestCompleted.doc.cs | 2 +- tests/Tests/CodeStandards/Requests.doc.cs | 4 +- .../Tests/MetaHeader/MetaHeaderHelperTests.cs | 40 +++++++++++++++++-- 11 files changed, 63 insertions(+), 29 deletions(-) diff --git a/tests/Tests.Core/Client/FixedResponseClient.cs b/tests/Tests.Core/Client/FixedResponseClient.cs index 9425d9c2489..0ed39779b7f 100644 --- a/tests/Tests.Core/Client/FixedResponseClient.cs +++ b/tests/Tests.Core/Client/FixedResponseClient.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System; +using System.Net; using System.Text; using Elasticsearch.Net; using Nest; @@ -16,10 +17,11 @@ public static IElasticClient Create( int statusCode = 200, Func modifySettings = null, string contentType = null, - Exception exception = null + Exception exception = null, + bool productCheckSucceeds = true ) { - var settings = CreateConnectionSettings(response, statusCode, modifySettings, contentType, exception); + var settings = CreateConnectionSettings(response, statusCode, modifySettings, contentType, exception, productCheckSucceeds); return new ElasticClient(settings); } @@ -28,7 +30,8 @@ public static ConnectionSettings CreateConnectionSettings( int statusCode = 200, Func modifySettings = null, string contentType = null, - Exception exception = null + Exception exception = null, + bool productCheckSucceeds = true ) { contentType ??= RequestData.DefaultJsonMimeType; @@ -51,7 +54,12 @@ public static ConnectionSettings CreateConnectionSettings( } } - var connection = new InMemoryConnection(responseBytes, statusCode, exception, contentType); + var productCheckResponse = productCheckSucceeds ? InMemoryConnection.ValidProductCheckResponse() : new InMemoryHttpResponse + { + StatusCode = 500 + }; + + var connection = new InMemoryConnection(responseBytes, statusCode, exception, contentType, productCheckResponse); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var defaultSettings = new ConnectionSettings(connectionPool, connection) .DefaultIndex("default-index"); diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/Exceptions/UnexpectedExceptions.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/Exceptions/UnexpectedExceptions.doc.cs index 35565033dc7..3b347eb1697 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/Exceptions/UnexpectedExceptions.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/Exceptions/UnexpectedExceptions.doc.cs @@ -104,7 +104,7 @@ [U] public async Task WillFailOverKnowConnectionExceptionButNotUnexpected() * * Here, pinging nodes on first use is enabled and the node on port 9200 throws an exception on ping; when this happens, * we still fallover to retry the ping on node on port 9201, where it succeeds. - * Following this, the client call on 9201 throws a hard exception that we are not able to recover from + * Following this, the client call on 9201 throws a hard exception that we are not able to recover from. */ [U] public async Task PingUnexceptedExceptionDoesFailOver() { diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/Failover/FallingOver.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/Failover/FallingOver.doc.cs index e1955fe1639..d699103b561 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/Failover/FallingOver.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/Failover/FallingOver.doc.cs @@ -15,7 +15,7 @@ public class FallingOver /**[[fail-over]] * === Fail over * When using a connection pool with more than one node, a request will be retried if - * the call to a node throws an exception or returns a 502, 503 or 504 response + * the call to a node throws an exception or returns a 502, 503 or 504 response. */ [U] public async Task ExceptionFallsOverToNextNode() @@ -27,7 +27,7 @@ public async Task ExceptionFallsOverToNextNode() .StaticConnectionPool() .Settings(s => s.DisablePing()) ); - + audit = await audit.TraceCall( new ClientCall { { AuditEvent.BadResponse, 9200 }, @@ -39,7 +39,7 @@ public async Task ExceptionFallsOverToNextNode() /**[[bad-gateway]] *==== 502 Bad Gateway * - * Will be treated as an error that requires retrying + * Will be treated as an error that requires retrying. */ [U] public async Task Http502FallsOver() @@ -63,7 +63,7 @@ public async Task Http502FallsOver() /**[[service-unavailable]] *==== 503 Service Unavailable * - * Will be treated as an error that requires retrying + * Will be treated as an error that requires retrying. */ [U] public async Task Http503FallsOver() @@ -87,7 +87,7 @@ public async Task Http503FallsOver() /**[[gateway-timeout]] *==== 504 Gateway Timeout * - * Will be treated as an error that requires retrying + * Will be treated as an error that requires retrying. */ [U] public async Task Http504FallsOver() @@ -112,7 +112,7 @@ public async Task Http504FallsOver() * If a call returns a __valid__ HTTP status code other than 502 or 503, the request won't be retried. * * IMPORTANT: Different requests may have different status codes that are deemed __valid__. For example, - * a *404 Not Found* response is a __valid__ status code for an index exists request + * a *404 Not Found* response is a __valid__ status code for an index exists request. */ [U] public async Task HttpTeapotDoesNotFallOver() diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/MaxRetries/RespectsMaxRetry.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/MaxRetries/RespectsMaxRetry.doc.cs index 73901545f69..f899b0af09c 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/MaxRetries/RespectsMaxRetry.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/MaxRetries/RespectsMaxRetry.doc.cs @@ -5,10 +5,8 @@ using System; using System.Threading.Tasks; using Elastic.Elasticsearch.Xunit.XunitPlumbing; -using Elasticsearch.Net; using Elasticsearch.Net.VirtualizedCluster; using Elasticsearch.Net.VirtualizedCluster.Audit; -using Tests.Framework; using static Elasticsearch.Net.AuditEvent; namespace Tests.ClientConcepts.ConnectionPooling.MaxRetries diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/FirstUsage.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/FirstUsage.doc.cs index a2c17bc4f8d..08b18cedcab 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/FirstUsage.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/Pinging/FirstUsage.doc.cs @@ -6,11 +6,9 @@ using System.Linq; using System.Threading.Tasks; using Elastic.Elasticsearch.Xunit.XunitPlumbing; -using Elasticsearch.Net; using Elasticsearch.Net.VirtualizedCluster; using Elasticsearch.Net.VirtualizedCluster.Audit; using FluentAssertions; -using Tests.Framework; using static Elasticsearch.Net.VirtualizedCluster.Rules.TimesHelper; using static Elasticsearch.Net.AuditEvent; diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/DisableSniffPingPerRequest.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/DisableSniffPingPerRequest.doc.cs index 9a2f5afff70..e74ac2a9bef 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/DisableSniffPingPerRequest.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/DisableSniffPingPerRequest.doc.cs @@ -36,7 +36,7 @@ [U] public async Task DisableSniff() .Settings(s => s.SniffOnStartup()) // <1> sniff on startup ); - /** Now We disable sniffing on the request so even though it's our first call, + /** Now we disable sniffing on the request so even though it's our first call, * we do not want to sniff on startup. * * Instead, the sniff on startup is deferred to the second call into the cluster that diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RequestTimeoutsOverrides.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RequestTimeoutsOverrides.doc.cs index a075219e942..fe46ae13b01 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RequestTimeoutsOverrides.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RequestTimeoutsOverrides.doc.cs @@ -17,14 +17,13 @@ public class RequestTimeoutsOverrides /**[[request-timeout]] * === Request timeouts * - * While you can specify Request time out globally you can override this per request too + * While you can specify Request time out globally, you can override this per request too. */ [U] public async Task RespectsRequestTimeoutOverride() { - - /** we set up a 10 node cluster with a global time out of 20 seconds. + /** We set up a 10 node cluster with a global time out of 20 seconds. * Each call on a node takes 10 seconds. So we can only try this call on 2 nodes * before the max request time out kills the client call. */ @@ -43,7 +42,7 @@ public async Task RespectsRequestTimeoutOverride() { MaxTimeoutReached } }, /** - * On the second request we specify a request timeout override to 80 seconds + * On the second request we specify a request timeout override to 80 seconds. * We should now see more nodes being tried. */ new ClientCall(r => r.RequestTimeout(TimeSpan.FromSeconds(80))) diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RespectsMaxRetryOverrides.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RespectsMaxRetryOverrides.doc.cs index 5693ac5a11f..d9d75afc067 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RespectsMaxRetryOverrides.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/RequestOverrides/RespectsMaxRetryOverrides.doc.cs @@ -5,10 +5,8 @@ using System; using System.Threading.Tasks; using Elastic.Elasticsearch.Xunit.XunitPlumbing; -using Elasticsearch.Net; using Elasticsearch.Net.VirtualizedCluster; using Elasticsearch.Net.VirtualizedCluster.Audit; -using Tests.Framework; using static Elasticsearch.Net.AuditEvent; namespace Tests.ClientConcepts.ConnectionPooling.RequestOverrides @@ -90,7 +88,6 @@ public async Task DoesNotRetryOnSingleNodeConnectionPool() { BadResponse, 9200 } } ); - } } } diff --git a/tests/Tests/ClientConcepts/Troubleshooting/LoggingWithOnRequestCompleted.doc.cs b/tests/Tests/ClientConcepts/Troubleshooting/LoggingWithOnRequestCompleted.doc.cs index 2225fcb0734..c407c52fa8c 100644 --- a/tests/Tests/ClientConcepts/Troubleshooting/LoggingWithOnRequestCompleted.doc.cs +++ b/tests/Tests/ClientConcepts/Troubleshooting/LoggingWithOnRequestCompleted.doc.cs @@ -59,7 +59,7 @@ public async Task OnRequestCompletedIsCalledWhenExceptionIsThrown() 500, connectionSettings => connectionSettings .ThrowExceptions() // <2> Always throw exceptions when a call results in an exception - .OnRequestCompleted(r => counter++) + .OnRequestCompleted(r => counter++), productCheckSucceeds: false ); Assert.Throws(() => client.RootNodeInfo()); // <3> Assert an exception is thrown and the counter is incremented diff --git a/tests/Tests/CodeStandards/Requests.doc.cs b/tests/Tests/CodeStandards/Requests.doc.cs index 6eb906ac2c9..107c0031cdf 100644 --- a/tests/Tests/CodeStandards/Requests.doc.cs +++ b/tests/Tests/CodeStandards/Requests.doc.cs @@ -42,7 +42,7 @@ public void BaseUriWithTrailingSlashIsRespected() public void BaseUriWithRelativePathIsRespected() { var pool = new SingleNodeConnectionPool(new Uri("http://localhost:9200/elasticsearch")); - var settings = new ConnectionSettings(pool, new InMemoryConnection()); + var settings = new ConnectionSettings(pool, new InMemoryConnection("elasticsearch")); var client = new ElasticClient(settings); var searchResponse = client.Search(s => s.AllIndices()); @@ -53,7 +53,7 @@ public void BaseUriWithRelativePathIsRespected() public void BaseUriWithRelativePathAndTrailingSlashIsRespected() { var pool = new SingleNodeConnectionPool(new Uri("http://localhost:9200/elasticsearch/")); - var settings = new ConnectionSettings(pool, new InMemoryConnection()); + var settings = new ConnectionSettings(pool, new InMemoryConnection("elasticsearch/")); var client = new ElasticClient(settings); var searchResponse = client.Search(s => s.AllIndices()); diff --git a/tests/Tests/MetaHeader/MetaHeaderHelperTests.cs b/tests/Tests/MetaHeader/MetaHeaderHelperTests.cs index cacf906bfaf..6646964c433 100644 --- a/tests/Tests/MetaHeader/MetaHeaderHelperTests.cs +++ b/tests/Tests/MetaHeader/MetaHeaderHelperTests.cs @@ -183,11 +183,13 @@ protected class SmallObject { public string Name { get; set; } } - + protected class TestableInMemoryConnection : IConnection { internal static readonly byte[] EmptyBody = Encoding.UTF8.GetBytes(""); + private readonly InMemoryHttpResponse _productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + private readonly Action _perRequestAssertion; private readonly List<(int, string)> _responses; private int _requestCounter = -1; @@ -202,8 +204,25 @@ public TestableInMemoryConnection(Action assertion, List<(int, stri async Task IConnection.RequestAsync(RequestData requestData, CancellationToken cancellationToken) { - Interlocked.Increment(ref _requestCounter); + if ("/".Equals(requestData.Uri.AbsolutePath, StringComparison.Ordinal) && requestData.Method == HttpMethod.GET) + { + // We don't add product checks to the request count + + _productCheckResponse.Headers.TryGetValue("X-elastic-product", out var productNames); + + requestData.MadeItToResponse = true; + + await using var ms = requestData.MemoryStreamFactory.Create(_productCheckResponse.ResponseBytes); + + await Task.Yield(); // avoids test deadlocks + + return ResponseBuilder.ToResponse( + requestData, null, _productCheckResponse.StatusCode, null, ms, + RequestData.DefaultJsonMimeType, productNames?.FirstOrDefault()); + } + Interlocked.Increment(ref _requestCounter); + _perRequestAssertion(requestData); await Task.Yield(); // avoids test deadlocks @@ -219,12 +238,27 @@ async Task IConnection.RequestAsync(RequestData requestDat var stream = !string.IsNullOrEmpty(response) ? requestData.MemoryStreamFactory.Create(Encoding.UTF8.GetBytes(response)) : requestData.MemoryStreamFactory.Create(EmptyBody); return await ResponseBuilder - .ToResponseAsync(requestData, null, statusCode, null, stream, RequestData.DefaultJsonMimeType, cancellationToken) + .ToResponseAsync(requestData, null, statusCode, null, stream, RequestData.DefaultJsonMimeType, "Elasticsearch", cancellationToken) .ConfigureAwait(false); } TResponse IConnection.Request(RequestData requestData) { + if ("/".Equals(requestData.Uri.AbsolutePath, StringComparison.Ordinal) && requestData.Method == HttpMethod.GET) + { + // We don't add product checks to the request count + + _productCheckResponse.Headers.TryGetValue("X-elastic-product", out var productNames); + + requestData.MadeItToResponse = true; + + using var ms = requestData.MemoryStreamFactory.Create(_productCheckResponse.ResponseBytes); + + return ResponseBuilder.ToResponse( + requestData, null, _productCheckResponse.StatusCode, null, ms, + RequestData.DefaultJsonMimeType, productNames?.FirstOrDefault()); + } + Interlocked.Increment(ref _requestCounter); _perRequestAssertion(requestData); From df85bc6992e0bc25732c6294b442b16a7f6b829e Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:37:45 +0100 Subject: [PATCH 17/25] Regnerate documentation --- .../exceptions/unexpected-exceptions.asciidoc | 2 +- .../failover/falling-over.asciidoc | 10 +- .../product-check-at-startup.asciidoc | 113 ++++++++++++++++++ .../disable-sniff-ping-per-request.asciidoc | 2 +- .../request-timeouts-overrides.asciidoc | 6 +- .../troubleshooting/audit-trail.asciidoc | 28 +++-- ...logging-with-on-request-completed.asciidoc | 2 +- docs/code-standards/requests.asciidoc | 4 +- docs/conventions.asciidoc | 4 + tests/Tests/conventions.asciidoc | 5 +- 10 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc diff --git a/docs/client-concepts/connection-pooling/exceptions/unexpected-exceptions.asciidoc b/docs/client-concepts/connection-pooling/exceptions/unexpected-exceptions.asciidoc index cfd47462d94..2cf769c826d 100644 --- a/docs/client-concepts/connection-pooling/exceptions/unexpected-exceptions.asciidoc +++ b/docs/client-concepts/connection-pooling/exceptions/unexpected-exceptions.asciidoc @@ -105,7 +105,7 @@ An unexpected hard exception on ping and sniff is something we *do* try to recov Here, pinging nodes on first use is enabled and the node on port 9200 throws an exception on ping; when this happens, we still fallover to retry the ping on node on port 9201, where it succeeds. -Following this, the client call on 9201 throws a hard exception that we are not able to recover from +Following this, the client call on 9201 throws a hard exception that we are not able to recover from. [source,csharp] ---- diff --git a/docs/client-concepts/connection-pooling/failover/falling-over.asciidoc b/docs/client-concepts/connection-pooling/failover/falling-over.asciidoc index 86bb222e1ec..9b6b742ecce 100644 --- a/docs/client-concepts/connection-pooling/failover/falling-over.asciidoc +++ b/docs/client-concepts/connection-pooling/failover/falling-over.asciidoc @@ -16,7 +16,7 @@ please modify the original csharp file found at the link and submit the PR with === Fail over When using a connection pool with more than one node, a request will be retried if -the call to a node throws an exception or returns a 502, 503 or 504 response +the call to a node throws an exception or returns a 502, 503 or 504 response. [source,csharp] ---- @@ -39,7 +39,7 @@ audit = await audit.TraceCall( [[bad-gateway]] ==== 502 Bad Gateway -Will be treated as an error that requires retrying +Will be treated as an error that requires retrying. [source,csharp] ---- @@ -62,7 +62,7 @@ audit = await audit.TraceCall( [[service-unavailable]] ==== 503 Service Unavailable -Will be treated as an error that requires retrying +Will be treated as an error that requires retrying. [source,csharp] ---- @@ -85,7 +85,7 @@ audit = await audit.TraceCall( [[gateway-timeout]] ==== 504 Gateway Timeout -Will be treated as an error that requires retrying +Will be treated as an error that requires retrying. [source,csharp] ---- @@ -108,7 +108,7 @@ audit = await audit.TraceCall( If a call returns a __valid__ HTTP status code other than 502 or 503, the request won't be retried. IMPORTANT: Different requests may have different status codes that are deemed __valid__. For example, -a *404 Not Found* response is a __valid__ status code for an index exists request +a *404 Not Found* response is a __valid__ status code for an index exists request. [source,csharp] ---- diff --git a/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc b/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc new file mode 100644 index 00000000000..9fbb3a2f084 --- /dev/null +++ b/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc @@ -0,0 +1,113 @@ +:ref_current: https://www.elastic.co/guide/en/elasticsearch/reference/7.x + +:github: https://github.com/elastic/elasticsearch-net + +:nuget: https://www.nuget.org/packages + +//// +IMPORTANT NOTE +============== +This file has been generated from https://github.com/elastic/elasticsearch-net/tree/7.x/src/Tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs. +If you wish to submit a PR for any spelling mistakes, typos or grammatical errors for this file, +please modify the original csharp file found at the link and submit the PR with that change. Thanks! +//// + +[[product-check-on-first-usage]] +=== Product check on first usage + +Since 7.14.0, the client performs a required product check during the first call. +This pre-flight product check allows us to establish the version of Elasticsearch we are communicating with. + +The product check requires one additional HTTP request to be sent to the server as part of the request pipeline. +Once the product check succeeds, no further product check HTTP requests are sent as part of the pipeline. + +[source,csharp] +---- +var audit = new Auditor(() => VirtualClusterWith + .Nodes(1) + .ClientCalls(r => r.SucceedAlways()) + .StaticConnectionPool() + .Settings(s => s.DisablePing()) +); + +audit = await audit.TraceCalls(skipProductCheck: false, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckSuccess, 9200 }, <1> + { HealthyResponse, 9200 } <2> + }, + new ClientCall() { + { HealthyResponse, 9200 } <3> + } +); +---- +<1> as this is the first call, the product check is executed +<2> following the product check, the actual request is sent +<3> subsequent calls no longer perform product check + +Here's an example with a single node cluster which fails for some reason during the first product check attempt. + +[source,csharp] +---- +var audit = new Auditor(() => VirtualClusterWith + .Nodes(1, productCheckAlwaysSucceeds: false) + .ProductCheck(r => r.Fails(TimesHelper.Once)) + .ProductCheck(r => r.SucceedAlways()) + .ClientCalls(r => r.SucceedAlways()) + .StaticConnectionPool() + .Settings(s => s.DisablePing()) +); + +audit = await audit.TraceCalls(skipProductCheck: false, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckFailure, 9200 }, <1> + { HealthyResponse, 9200 } <2> + }, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckSuccess, 9200 }, <3> + { HealthyResponse, 9200 } + }, + new ClientCall() { + { HealthyResponse, 9200 } <4> + } +); +---- +<1> as this is the first call, the product check is executed, but fails +<2> the actual request is still sent and succeeds +<3> as the previous product check failed, it runs on the second call +<4> subsequent calls no longer perform product check + +Here's an example with a three node cluster which fails for some reason during the first and second product check attempts. + +[source,csharp] +---- +var audit = new Auditor(() => VirtualClusterWith + .Nodes(3, productCheckAlwaysSucceeds: false) + .ProductCheck(r => r.FailAlways()) + .ProductCheck(r => r.OnPort(9202).SucceedAlways()) + .ClientCalls(r => r.SucceedAlways()) + .StaticConnectionPool() + .Settings(s => s.DisablePing()) +); + +audit = await audit.TraceCalls(skipProductCheck: false, + new ClientCall() { + { ProductCheckOnStartup }, + { ProductCheckFailure, 9200 }, <1> + { ProductCheckFailure, 9201 }, <2> + { ProductCheckSuccess, 9202 }, <3> + { HealthyResponse, 9200 } <4> + }, + new ClientCall() { + { HealthyResponse, 9201 } <5> + } +); +---- +<1> this is the first call, the product check is executed, but fails on this node +<2> the next node is also tried and fails +<3> the third node is tried, successfully responds and the product check succeeds +<4> the actual request is sent and succeeds +<5> subsequent calls no longer perform product check + diff --git a/docs/client-concepts/connection-pooling/request-overrides/disable-sniff-ping-per-request.asciidoc b/docs/client-concepts/connection-pooling/request-overrides/disable-sniff-ping-per-request.asciidoc index 9671cbc341a..13e73316f35 100644 --- a/docs/client-concepts/connection-pooling/request-overrides/disable-sniff-ping-per-request.asciidoc +++ b/docs/client-concepts/connection-pooling/request-overrides/disable-sniff-ping-per-request.asciidoc @@ -36,7 +36,7 @@ var audit = new Auditor(() => VirtualClusterWith ---- <1> sniff on startup -Now We disable sniffing on the request so even though it's our first call, +Now we disable sniffing on the request so even though it's our first call, we do not want to sniff on startup. Instead, the sniff on startup is deferred to the second call into the cluster that diff --git a/docs/client-concepts/connection-pooling/request-overrides/request-timeouts-overrides.asciidoc b/docs/client-concepts/connection-pooling/request-overrides/request-timeouts-overrides.asciidoc index 40453cc884f..aba37bb83fb 100644 --- a/docs/client-concepts/connection-pooling/request-overrides/request-timeouts-overrides.asciidoc +++ b/docs/client-concepts/connection-pooling/request-overrides/request-timeouts-overrides.asciidoc @@ -15,9 +15,9 @@ please modify the original csharp file found at the link and submit the PR with [[request-timeout]] === Request timeouts -While you can specify Request time out globally you can override this per request too +While you can specify Request time out globally, you can override this per request too. -we set up a 10 node cluster with a global time out of 20 seconds. +We set up a 10 node cluster with a global time out of 20 seconds. Each call on a node takes 10 seconds. So we can only try this call on 2 nodes before the max request time out kills the client call. @@ -38,7 +38,7 @@ audit = await audit.TraceCalls( { MaxTimeoutReached } }, /** - * On the second request we specify a request timeout override to 80 seconds + * On the second request we specify a request timeout override to 80 seconds. * We should now see more nodes being tried. */ new ClientCall(r => r.RequestTimeout(TimeSpan.FromSeconds(80))) diff --git a/docs/client-concepts/troubleshooting/audit-trail.asciidoc b/docs/client-concepts/troubleshooting/audit-trail.asciidoc index 7a8c43dde2b..ab56193d5c3 100644 --- a/docs/client-concepts/troubleshooting/audit-trail.asciidoc +++ b/docs/client-concepts/troubleshooting/audit-trail.asciidoc @@ -20,7 +20,7 @@ occur when a request is made. This audit trail is available on the response as d following example. We'll use a Sniffing connection pool here since it sniffs on startup and pings before -first usage, so we can get an audit trail with a few events out +first usage, so we can get an audit trail with a few events out. [source,csharp] ---- @@ -33,7 +33,7 @@ var connectionSettings = new ConnectionSettings(pool) var client = new ElasticClient(connectionSettings); ---- -After issuing the following request +After issuing the following request: [source,csharp] ---- @@ -48,10 +48,12 @@ readable fashion, similar to .... Valid NEST response built from a successful low level call on POST: /project/doc/_search # Audit trail of this API call: - - [1] SniffOnStartup: Took: 00:00:00.0360264 - - [2] SniffSuccess: Node: http://localhost:9200/ Took: 00:00:00.0310228 - - [3] PingSuccess: Node: http://127.0.0.1:9200/ Took: 00:00:00.0115074 - - [4] HealthyResponse: Node: http://127.0.0.1:9200/ Took: 00:00:00.1477640 + - [1] ProductCheckOnStartup: Took: 00:00:00.1014934 + - [2] ProductCheckSuccess: Node: http://ipv4.fiddler:9200/ Took: 00:00:00.1002862 + - [3] SniffOnStartup: Took: 00:00:00.0440780 + - [4] SniffSuccess: Node: http://localhost:9200/ Took: 00:00:00.0257506 + - [5] PingSuccess: Node: http://127.0.0.1:9200/ Took: 00:00:00.0115074 + - [6] HealthyResponse: Node: http://127.0.0.1:9200/ Took: 00:00:00.1477640 # Request: # Response: @@ -69,15 +71,17 @@ But can also be accessed manually: [source,csharp] ---- -response.ApiCall.AuditTrail.Count.Should().Be(4, "{0}", debug); -response.ApiCall.AuditTrail[0].Event.Should().Be(AuditEvent.SniffOnStartup, "{0}", debug); -response.ApiCall.AuditTrail[1].Event.Should().Be(AuditEvent.SniffSuccess, "{0}", debug); -response.ApiCall.AuditTrail[2].Event.Should().Be(AuditEvent.PingSuccess, "{0}", debug); -response.ApiCall.AuditTrail[3].Event.Should().Be(AuditEvent.HealthyResponse, "{0}", debug); +response.ApiCall.AuditTrail.Count.Should().Be(6, "{0}", debug); +response.ApiCall.AuditTrail[0].Event.Should().Be(AuditEvent.ProductCheckOnStartup, "{0}", debug); +response.ApiCall.AuditTrail[1].Event.Should().Be(AuditEvent.ProductCheckSuccess, "{0}", debug); +response.ApiCall.AuditTrail[2].Event.Should().Be(AuditEvent.SniffOnStartup, "{0}", debug); +response.ApiCall.AuditTrail[3].Event.Should().Be(AuditEvent.SniffSuccess, "{0}", debug); +response.ApiCall.AuditTrail[4].Event.Should().Be(AuditEvent.PingSuccess, "{0}", debug); +response.ApiCall.AuditTrail[5].Event.Should().Be(AuditEvent.HealthyResponse, "{0}", debug); ---- Each audit has a started and ended `DateTime` on it that will provide -some understanding of how long it took +some understanding of how long it took. [source,csharp] ---- diff --git a/docs/client-concepts/troubleshooting/logging-with-on-request-completed.asciidoc b/docs/client-concepts/troubleshooting/logging-with-on-request-completed.asciidoc index 5da3d6e9b0a..d23610418a2 100644 --- a/docs/client-concepts/troubleshooting/logging-with-on-request-completed.asciidoc +++ b/docs/client-concepts/troubleshooting/logging-with-on-request-completed.asciidoc @@ -51,7 +51,7 @@ var client = FixedResponseClient.Create( <1> 500, connectionSettings => connectionSettings .ThrowExceptions() <2> - .OnRequestCompleted(r => counter++) + .OnRequestCompleted(r => counter++), productCheckSucceeds: false ); Assert.Throws(() => client.RootNodeInfo()); <3> diff --git a/docs/code-standards/requests.asciidoc b/docs/code-standards/requests.asciidoc index e769232a133..098327580e0 100644 --- a/docs/code-standards/requests.asciidoc +++ b/docs/code-standards/requests.asciidoc @@ -34,14 +34,14 @@ var searchResponse = client.Search(s => s.AllIndices()); searchResponse.ApiCall.Uri.ToString().Should().Be("http://localhost:9200/_all/_search?typed_keys=true"); var pool = new SingleNodeConnectionPool(new Uri("http://localhost:9200/elasticsearch")); -var settings = new ConnectionSettings(pool, new InMemoryConnection()); +var settings = new ConnectionSettings(pool, new InMemoryConnection("elasticsearch")); var client = new ElasticClient(settings); var searchResponse = client.Search(s => s.AllIndices()); searchResponse.ApiCall.Uri.ToString().Should().Be("http://localhost:9200/elasticsearch/_all/_search?typed_keys=true"); var pool = new SingleNodeConnectionPool(new Uri("http://localhost:9200/elasticsearch/")); -var settings = new ConnectionSettings(pool, new InMemoryConnection()); +var settings = new ConnectionSettings(pool, new InMemoryConnection("elasticsearch/")); var client = new ElasticClient(settings); var searchResponse = client.Search(s => s.AllIndices()); diff --git a/docs/conventions.asciidoc b/docs/conventions.asciidoc index 2a7c9622abb..39753d659b1 100644 --- a/docs/conventions.asciidoc +++ b/docs/conventions.asciidoc @@ -35,6 +35,8 @@ Conventions that apply to both Elasticsearch.Net and NEST * <> +* <> + * <> * <> @@ -97,6 +99,8 @@ include::{building-blocks}/date-time-providers.asciidoc[] include::{building-blocks}/keeping-track-of-nodes.asciidoc[] +include::{product-checking}/TODO/lifetimes.asciidoc[] + [[sniffing-behaviour]] == Sniffing behaviour diff --git a/tests/Tests/conventions.asciidoc b/tests/Tests/conventions.asciidoc index a90a22b17a2..fd57b52f8f9 100644 --- a/tests/Tests/conventions.asciidoc +++ b/tests/Tests/conventions.asciidoc @@ -1,4 +1,4 @@ -:output-dir: client-concepts/connection-pooling +:output-dir: client-concepts/connection-pooling :building-blocks: {output-dir}/building-blocks :sniffing: {output-dir}/sniffing :pinging: {output-dir}/pinging @@ -17,6 +17,7 @@ Conventions that apply to both Elasticsearch.Net and NEST - <> - <> - <> +- <> - <> - <> - <> @@ -59,6 +60,8 @@ include::{building-blocks}/request-pipelines.asciidoc[] include::{building-blocks}/date-time-providers.asciidoc[] include::{building-blocks}/keeping-track-of-nodes.asciidoc[] +include::{product-checking}/product-checking/product-check-at-startup.asciidoc[] + [[sniffing-behaviour]] == Sniffing behaviour From 3606bccaae3087892736ae551828e60a6e466236 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:44:07 +0100 Subject: [PATCH 18/25] Fix encoding for new file --- .../Rules/ProductCheckRule.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs b/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs index 3b28f45d53f..d69e5ca2940 100644 --- a/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs +++ b/src/Elasticsearch.Net.VirtualizedCluster/Rules/ProductCheckRule.cs @@ -1,4 +1,4 @@ -// Licensed to Elasticsearch B.V under one or more agreements. +// Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information From 9aff04db4b79d69746c001a0a34f717959dded74 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:58:27 +0100 Subject: [PATCH 19/25] Remove async test --- .../BuildingBlocks/RequestPipelines.doc.cs | 71 ------------------- 1 file changed, 71 deletions(-) diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs index c46ead492b8..03f338ed1bc 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/BuildingBlocks/RequestPipelines.doc.cs @@ -189,77 +189,6 @@ public void FirstUsageCheckConcurrentThreads() semaphoreSlim.CurrentCount.Should().Be(1); } - // hide - [U] public async Task FirstUsageCheckConcurrentThreadsAsync() - { - //hide - var response = new - { - cluster_name = "elasticsearch", - nodes = new - { - node1 = new - { - name = "Node Name 1", - transport_address = "127.0.0.1:9300", - host = "127.0.0.1", - ip = "127.0.01", - version = "5.0.0-alpha3", - build_hash = "e455fd0", - roles = new List(), - http = new - { - bound_address = new[] { "127.0.0.1:9200" } - }, - settings = new Dictionary - { - { "cluster.name", "elasticsearch" }, - { "node.name", "Node Name 1" } - } - } - } - }; - - //hide - var responseBody = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(response)); - - /** We can demonstrate this with the following example. First, let's configure - * a custom `IConnection` implementation that's simply going to return a known - * 200 response after one second - */ - var inMemoryConnection = new WaitingInMemoryConnection( - TimeSpan.FromSeconds(1), - responseBody); - - /** - * Next, we create a <> using our - * custom connection and a timeout for how long a request can take before the client - * times out - */ - var sniffingPipeline = CreatePipeline( - uris => new SniffingConnectionPool(uris), - connection: inMemoryConnection, - settingsSelector: s => s.RequestTimeout(TimeSpan.FromSeconds(2))); - - /**Now, with a `SemaphoreSlim` in place that allows only one thread to enter at a time, - * start three tasks that will initiate a sniff on startup. - * - * The first task will successfully sniff on startup with the remaining two waiting - * tasks exiting without exception. The `SemaphoreSlim` is also released, ready for - * when sniffing needs to take place again - */ - var semaphoreSlim = new SemaphoreSlim(1, 1); - - var task1 = sniffingPipeline.FirstPoolUsageAsync(semaphoreSlim, CancellationToken.None); - var task2 = sniffingPipeline.FirstPoolUsageAsync(semaphoreSlim, CancellationToken.None); - var task3 = sniffingPipeline.FirstPoolUsageAsync(semaphoreSlim, CancellationToken.None); - - var exception = await Record.ExceptionAsync(async () => await Task.WhenAll(task1, task2, task3)); - - exception.Should().BeNull(); - semaphoreSlim.CurrentCount.Should().Be(1); - } - /**==== Sniff on connection failure */ [U] public void SniffsOnConnectionFailure() From 500c3dc923130860bda79ee81acb11912b458622 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 15 Jun 2021 15:58:38 +0100 Subject: [PATCH 20/25] Update link in documentation --- docs/conventions.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conventions.asciidoc b/docs/conventions.asciidoc index 39753d659b1..2564ab0619c 100644 --- a/docs/conventions.asciidoc +++ b/docs/conventions.asciidoc @@ -99,7 +99,7 @@ include::{building-blocks}/date-time-providers.asciidoc[] include::{building-blocks}/keeping-track-of-nodes.asciidoc[] -include::{product-checking}/TODO/lifetimes.asciidoc[] +include::{product-checking}/product-check-at-startup.asciidoc[] [[sniffing-behaviour]] == Sniffing behaviour From 159363fd8e4b2b0df3623808f104814237d4914a Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Wed, 16 Jun 2021 09:26:30 +0100 Subject: [PATCH 21/25] Fix documentation --- .../product-checking/product-check-at-startup.asciidoc | 6 +++--- docs/conventions.asciidoc | 4 ++-- .../ProductChecking/ProductCheckAtStartup.doc.cs | 5 +++-- tests/Tests/conventions.asciidoc | 5 ++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc b/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc index 9fbb3a2f084..2b2066a89aa 100644 --- a/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc +++ b/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc @@ -12,10 +12,10 @@ If you wish to submit a PR for any spelling mistakes, typos or grammatical error please modify the original csharp file found at the link and submit the PR with that change. Thanks! //// -[[product-check-on-first-usage]] -=== Product check on first usage +[[product-check-at-startup]] +== Product check at startup -Since 7.14.0, the client performs a required product check during the first call. +Since v7.14.0, the client performs a required product check before the first call. This pre-flight product check allows us to establish the version of Elasticsearch we are communicating with. The product check requires one additional HTTP request to be sent to the server as part of the request pipeline. diff --git a/docs/conventions.asciidoc b/docs/conventions.asciidoc index 2564ab0619c..ef176d1475a 100644 --- a/docs/conventions.asciidoc +++ b/docs/conventions.asciidoc @@ -35,7 +35,7 @@ Conventions that apply to both Elasticsearch.Net and NEST * <> -* <> +* <> * <> @@ -99,7 +99,7 @@ include::{building-blocks}/date-time-providers.asciidoc[] include::{building-blocks}/keeping-track-of-nodes.asciidoc[] -include::{product-checking}/product-check-at-startup.asciidoc[] +include::client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc[] [[sniffing-behaviour]] == Sniffing behaviour diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs index 4acf68ed4e8..8325929c9d1 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs @@ -13,9 +13,10 @@ namespace Tests.ClientConcepts.ConnectionPooling.ProductChecking { public class ProductCheckAtStartup { - /**=== Product check on first usage + /**[[product-check-at-startup]] + * == Product check at startup * - * Since 7.14.0, the client performs a required product check during the first call. + * Since v7.14.0, the client performs a required product check before the first call. * This pre-flight product check allows us to establish the version of Elasticsearch we are communicating with. * * The product check requires one additional HTTP request to be sent to the server as part of the request pipeline. diff --git a/tests/Tests/conventions.asciidoc b/tests/Tests/conventions.asciidoc index fd57b52f8f9..22ce6a7b6bb 100644 --- a/tests/Tests/conventions.asciidoc +++ b/tests/Tests/conventions.asciidoc @@ -17,7 +17,7 @@ Conventions that apply to both Elasticsearch.Net and NEST - <> - <> - <> -- <> +- <> - <> - <> - <> @@ -60,7 +60,7 @@ include::{building-blocks}/request-pipelines.asciidoc[] include::{building-blocks}/date-time-providers.asciidoc[] include::{building-blocks}/keeping-track-of-nodes.asciidoc[] -include::{product-checking}/product-checking/product-check-at-startup.asciidoc[] +include::client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc[] [[sniffing-behaviour]] == Sniffing behaviour @@ -84,7 +84,6 @@ include::{sniffing}/role-detection.asciidoc[] include::{pinging}/first-usage.asciidoc[] include::{pinging}/revival.asciidoc[] - include::{round-robin}/round-robin.asciidoc[] include::{round-robin}/skip-dead-nodes.asciidoc[] From 2e2b4514abb7295add8c228af233657c439fe009 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Fri, 18 Jun 2021 13:33:07 +0100 Subject: [PATCH 22/25] Optimise validation for 7.14 versions --- .../Transport/Pipeline/RequestPipeline.cs | 334 +++++++++--------- .../Tests/ClientConcepts/ProductCheckTests.cs | 146 +++----- 2 files changed, 221 insertions(+), 259 deletions(-) diff --git a/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs b/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs index 97308b9d77d..0d717f51ff6 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs @@ -18,14 +18,17 @@ namespace Elasticsearch.Net { public class RequestPipeline : IRequestPipeline { + private const string ExpectedBuildFlavor = "default"; + private const string ExpectedProductName = "Elasticsearch"; + private const string ExpectedTagLine = "You Know, for Search"; private const string NoNodesAttemptedMessage = "No nodes were attempted, this can happen when a node predicate does not match any nodes"; + public const string UndeterminedProductWarning = + "TODO: The client could not determine if the server is running the official Elasticsearch product."; + private static readonly Version MinVersion = new(6, 0, 0); private static readonly Version Version7 = new(7, 0, 0); private static readonly Version Version714 = new(7, 14, 0); - private const string ExpectedTagLine = "You Know, for Search"; - private const string ExpectedBuildFlavor = "default"; - private const string ExpectedProductName = "Elasticsearch"; private readonly IConnection _connection; private readonly IConnectionPool _connectionPool; @@ -33,8 +36,6 @@ public class RequestPipeline : IRequestPipeline private readonly IMemoryStreamFactory _memoryStreamFactory; private readonly IConnectionConfigurationValues _settings; - private static DiagnosticSource DiagnosticSource { get; } = new DiagnosticListener(DiagnosticSources.RequestPipeline.SourceName); - public RequestPipeline( IConnectionConfigurationValues configurationValues, IDateTimeProvider dateTimeProvider, @@ -119,6 +120,8 @@ public bool StaleClusterState public DateTime StartedOn { get; private set; } + private static DiagnosticSource DiagnosticSource { get; } = new DiagnosticListener(DiagnosticSources.RequestPipeline.SourceName); + private TimeSpan PingTimeout => RequestConfiguration?.PingTimeout ?? _settings.PingTimeout @@ -130,11 +133,7 @@ public bool StaleClusterState private TimeSpan RequestTimeout => RequestConfiguration?.RequestTimeout ?? _settings.RequestTimeout; - private NodesInfoRequestParameters SniffParameters => new NodesInfoRequestParameters - { - Timeout = PingTimeout, - FlatSettings = true - }; + private NodesInfoRequestParameters SniffParameters => new NodesInfoRequestParameters { Timeout = PingTimeout, FlatSettings = true }; void IDisposable.Dispose() => Dispose(); @@ -160,7 +159,8 @@ public TResponse CallElasticsearch(RequestData requestData) where TResponse : class, IElasticsearchResponse, new() { using var audit = Audit(HealthyResponse, requestData.Node); - using var diagnostic = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData); + using var diagnostic = + DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData); audit.Path = requestData.PathAndQuery; try @@ -180,7 +180,8 @@ public async Task CallElasticsearchAsync(RequestData reque where TResponse : class, IElasticsearchResponse, new() { using var audit = Audit(HealthyResponse, requestData.Node); - using var diagnostic = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData); + using var diagnostic = + DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.CallElasticsearch, requestData); audit.Path = requestData.PathAndQuery; try @@ -196,32 +197,6 @@ public async Task CallElasticsearchAsync(RequestData reque } } - public const string UndeterminedProductWarning = - "TODO: The client could not determine if the server is running the official Elasticsearch product."; - - private TResponse PostCallElasticsearch(RequestData requestData, TResponse response, Diagnostic diagnostic, Auditable audit) - where TResponse : class, IElasticsearchResponse, new() - { - // Add additional warning to debug information if the product could not be determined and may not be Elasticsearch - if (_connectionPool.ProductCheckStatus == ProductCheckStatus.UndeterminedProduct && response.ApiCall is ApiCallDetails callDetails) - { - Debug.WriteLine(UndeterminedProductWarning); - callDetails.BuildDebugInformationPrefix = sb => - { - sb.AppendLine("# Warnings:"); - sb.AppendLine($"- {UndeterminedProductWarning}"); - }; - } - - diagnostic.EndState = response.ApiCall; - response.ApiCall.AuditTrail = AuditTrail; - audit.Stop(); - ThrowBadAuthPipelineExceptionWhenNeeded(response.ApiCall, response); - if (!response.ApiCall.Success) - audit.Event = requestData.OnFailureAuditEvent; - return response; - } - public ElasticsearchClientException CreateClientException( TResponse response, IApiCallDetails callDetails, RequestData data, List pipelineExceptions ) @@ -271,9 +246,7 @@ public ElasticsearchClientException CreateClientException( var clientException = new ElasticsearchClientException(pipelineFailure, exceptionMessage, innerException) { - Request = data, - Response = callDetails, - AuditTrail = AuditTrail + Request = data, Response = callDetails, AuditTrail = AuditTrail }; return clientException; @@ -298,7 +271,6 @@ public void FirstPoolUsage(SemaphoreSlim semaphore) try { if (_connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked) - { using (Audit(ProductCheckOnStartup)) { var nodes = _connectionPool.Nodes.ToArray(); // Isolated copy of nodes for the product check @@ -309,15 +281,14 @@ public void FirstPoolUsage(SemaphoreSlim semaphore) ProductCheck(node); } else - { // We determine the product from the first node which successfully responds. - for (var i = 0; i < nodes.Length && _connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked && !IsTakingTooLong; i++) + for (var i = 0; + i < nodes.Length && _connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked && !IsTakingTooLong; + i++) ProductCheck(nodes[i]); - } StartedOn = _dateTimeProvider.Now(); } - } if (_connectionPool.ProductCheckStatus == ProductCheckStatus.InvalidProduct) throw new InvalidProductException(); @@ -360,7 +331,6 @@ public async Task FirstPoolUsageAsync(SemaphoreSlim semaphore, CancellationToken try { if (_connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked) - { using (Audit(ProductCheckOnStartup)) { var nodes = _connectionPool.Nodes.ToArray(); // Isolated copy of nodes for the product check @@ -371,27 +341,24 @@ public async Task FirstPoolUsageAsync(SemaphoreSlim semaphore, CancellationToken await ProductCheckAsync(node, cancellationToken).ConfigureAwait(false); } else - { // We determine the product from the first node which successfully responds. - for (var i = 0; i < nodes.Length && _connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked && !IsTakingTooLong; i++) + for (var i = 0; + i < nodes.Length && _connectionPool.ProductCheckStatus == ProductCheckStatus.NotChecked && !IsTakingTooLong; + i++) await ProductCheckAsync(nodes[i], cancellationToken).ConfigureAwait(false); - } StartedOn = _dateTimeProvider.Now(); } - } if (_connectionPool.ProductCheckStatus == ProductCheckStatus.InvalidProduct) throw new InvalidProductException(); if (FirstPoolUsageNeedsSniffing) - { using (Audit(SniffOnStartup)) { await SniffAsync(cancellationToken).ConfigureAwait(false); _connectionPool.SniffedOnStartup = true; } - } } finally { @@ -509,96 +476,6 @@ public async Task PingAsync(Node node, CancellationToken cancellationToken) } } - internal void ProductCheck(Node node) - { - // We don't throw an exception on failure here since we don't want this new check to break consumers on upgrade. - - var requestData = CreateRootPathRequestData(node); - using var audit = Audit(ProductCheckSuccess, node); - - try - { - audit.Path = requestData.PathAndQuery; - var response = _connection.Request(requestData); - var succeeded = ApplyProductCheckRules(response); - audit.Stop(); - - if (!succeeded) - audit.Event = ProductCheckFailure; - } - catch (Exception e) - { - audit.Event = ProductCheckFailure; - audit.Exception = e; - } - } - - internal async Task ProductCheckAsync(Node node, CancellationToken cancellationToken) - { - // We don't throw an exception on failure here since we don't want this new check to break consumers on upgrade. - - var requestData = CreateRootPathRequestData(node); - using var audit = Audit(ProductCheckSuccess, node); - - try - { - audit.Path = requestData.PathAndQuery; - var response = await _connection.RequestAsync(requestData, cancellationToken).ConfigureAwait(false); - var succeeded = ApplyProductCheckRules(response); - audit.Stop(); - - if (!succeeded) - audit.Event = ProductCheckFailure; - } - catch (Exception e) - { - audit.Event = ProductCheckFailure; - audit.Exception = e; - } - } - - private bool ApplyProductCheckRules(RootResponse response) - { - if (response.HttpStatusCode.HasValue && (response.HttpStatusCode.Value == 401 || response.HttpStatusCode.Value == 403)) - { - // The call to the root path requires monitor permissions. If the current use lacks those, we cannot perform product validation. - _connectionPool.ProductCheckStatus = ProductCheckStatus.UndeterminedProduct; - return true; - } - - if (!response.Success) return false; - - // Start by assuming the product is valid - _connectionPool.ProductCheckStatus = ProductCheckStatus.ValidProduct; - - // We expect to have a version number from the build version. - // If we don't, the product is not Elasticsearch - if (string.IsNullOrEmpty(response.Version?.Number)) - { - _connectionPool.ProductCheckStatus = ProductCheckStatus.InvalidProduct; - } - else - { - var versionNumber = response.Version.Number; - var indexOfSuffix = versionNumber.IndexOf("-", StringComparison.Ordinal); - - if (indexOfSuffix > 0) - versionNumber = versionNumber.Substring(0, indexOfSuffix); - - var version = new Version(versionNumber); - - if (version < MinVersion || - version < Version7 && !ExpectedTagLine.Equals(response.Tagline) || - version >= Version7 && version < Version714 && (!ExpectedBuildFlavor.Equals(response.Version?.BuildFlavor, StringComparison.Ordinal) || !ExpectedTagLine.Equals(response.Tagline, StringComparison.Ordinal)) || - version >= Version714 && !ExpectedProductName.Equals(response.ApiCall.ProductName, StringComparison.Ordinal)) - { - _connectionPool.ProductCheckStatus = ProductCheckStatus.InvalidProduct; - } - } - - return true; - } - public void Sniff() { var exceptions = new List(); @@ -608,7 +485,6 @@ public void Sniff() using (var audit = Audit(SniffSuccess, node)) using (var d = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.Sniff, requestData)) using (DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.Sniff, requestData)) - { try { audit.Path = requestData.PathAndQuery; @@ -631,7 +507,6 @@ public void Sniff() audit.Exception = e; exceptions.Add(e); } - } } throw new PipelineException(PipelineFailure.SniffFailure, exceptions.AsAggregateOrFirst()); @@ -645,7 +520,6 @@ public async Task SniffAsync(CancellationToken cancellationToken) var requestData = CreateSniffRequestData(node); using (var audit = Audit(SniffSuccess, node)) using (var d = DiagnosticSource.Diagnose(DiagnosticSources.RequestPipeline.Sniff, requestData)) - { try { audit.Path = requestData.PathAndQuery; @@ -667,7 +541,6 @@ public async Task SniffAsync(CancellationToken cancellationToken) audit.Exception = e; exceptions.Add(e); } - } } throw new PipelineException(PipelineFailure.SniffFailure, exceptions.AsAggregateOrFirst()); @@ -719,11 +592,157 @@ public void ThrowNoNodesAttempted(RequestData requestData, List(RequestData requestData, TResponse response, + Diagnostic diagnostic, Auditable audit + ) + where TResponse : class, IElasticsearchResponse, new() + { + // Add additional warning to debug information if the product could not be determined and may not be Elasticsearch + if (_connectionPool.ProductCheckStatus == ProductCheckStatus.UndeterminedProduct && response.ApiCall is ApiCallDetails callDetails) + { + Debug.WriteLine(UndeterminedProductWarning); + callDetails.BuildDebugInformationPrefix = sb => { - Request = requestData, - AuditTrail = AuditTrail + sb.AppendLine("# Warnings:"); + sb.AppendLine($"- {UndeterminedProductWarning}"); }; + } + + diagnostic.EndState = response.ApiCall; + response.ApiCall.AuditTrail = AuditTrail; + audit.Stop(); + ThrowBadAuthPipelineExceptionWhenNeeded(response.ApiCall, response); + if (!response.ApiCall.Success) + audit.Event = requestData.OnFailureAuditEvent; + return response; + } + + internal void ProductCheck(Node node) + { + // We don't throw an exception on failure here since we don't want this new check to break consumers on upgrade. + + var requestData = CreateRootPathRequestData(node); + using var audit = Audit(ProductCheckSuccess, node); + + try + { + audit.Path = requestData.PathAndQuery; + var response = _connection.Request(requestData); + var succeeded = ApplyProductCheckRules(response); + audit.Stop(); + + if (!succeeded) + audit.Event = ProductCheckFailure; + } + catch (Exception e) + { + audit.Event = ProductCheckFailure; + audit.Exception = e; + } + } + + internal async Task ProductCheckAsync(Node node, CancellationToken cancellationToken) + { + // We don't throw an exception on failure here since we don't want this new check to break consumers on upgrade. + + var requestData = CreateRootPathRequestData(node); + using var audit = Audit(ProductCheckSuccess, node); + + try + { + audit.Path = requestData.PathAndQuery; + var response = await _connection.RequestAsync(requestData, cancellationToken).ConfigureAwait(false); + var succeeded = ApplyProductCheckRules(response); + audit.Stop(); + + if (!succeeded) + audit.Event = ProductCheckFailure; + } + catch (Exception e) + { + audit.Event = ProductCheckFailure; + audit.Exception = e; + } + } + + private bool ApplyProductCheckRules(RootResponse response) + { + var productName = response.ApiCall?.ProductName; + + // Fast path for v7.14+ where the header should have been sent + if (response.Success && !string.IsNullOrEmpty(productName)) + { + _connectionPool.ProductCheckStatus = ExpectedProductName.Equals(productName, StringComparison.Ordinal) + ? ProductCheckStatus.ValidProduct + : ProductCheckStatus.InvalidProduct; + + return true; + } + + if (response.HttpStatusCode.HasValue && (response.HttpStatusCode.Value == 401 || response.HttpStatusCode.Value == 403)) + { + // The call to the root path requires monitor permissions. If the current use lacks those, we cannot perform product validation. + _connectionPool.ProductCheckStatus = ProductCheckStatus.UndeterminedProduct; + return true; + } + + if (!response.Success) return false; + + // Start by assuming the product is valid + _connectionPool.ProductCheckStatus = ProductCheckStatus.ValidProduct; + + // We expect to have a version number from the build version. + // If we don't, the product is not Elasticsearch + if (string.IsNullOrEmpty(response.Version?.Number)) + _connectionPool.ProductCheckStatus = ProductCheckStatus.InvalidProduct; + else + { + var versionNumber = response.Version.Number; + var indexOfSuffix = versionNumber.IndexOf("-", StringComparison.Ordinal); + + if (indexOfSuffix > 0) + versionNumber = versionNumber.Substring(0, indexOfSuffix); + + var version = new Version(versionNumber); + + if (VersionTooLow(version) || + TagLineInvalid(version, response) || + TagLineOrBuildFlavorInvalid(version, response) || + Version714InvalidHeader(version, productName)) + _connectionPool.ProductCheckStatus = ProductCheckStatus.InvalidProduct; + } + + // Elasticsearch should be version 6.0.0 or greater + // Note: For best compatibility, the client should not be used with versions prior to 7.0.0, but we do not enforce that here + static bool VersionTooLow(Version version) + { + return version < MinVersion; + } + + // Between v6.0.0 and 6.99.99, we expect the tagline to match the expected value + static bool TagLineInvalid(Version version, RootResponse response) + { + return version < Version7 && !ExpectedTagLine.Equals(response.Tagline); + } + + // Between v7.0.0 and 7.13.99, we expect the tagline and build flavor to match expected values + static bool TagLineOrBuildFlavorInvalid(Version version, RootResponse response) + { + return version >= Version7 && version < Version714 + && (!ExpectedBuildFlavor.Equals(response.Version?.BuildFlavor, StringComparison.Ordinal) + || !ExpectedTagLine.Equals(response.Tagline, StringComparison.Ordinal)); + } + + // Between v7.0.0 and 7.13.99, we expect the tagline and build flavor to match expected values + static bool Version714InvalidHeader(Version version, string productName) + { + return version >= Version714 && !ExpectedProductName.Equals(productName, StringComparison.Ordinal); + } + + return true; } private bool PingDisabled(Node node) => @@ -768,23 +787,16 @@ private RequestData CreateRootPathRequestData(Node node) private static void ThrowBadAuthPipelineExceptionWhenNeeded(IApiCallDetails details, IElasticsearchResponse response = null) { if (details?.HttpStatusCode == 401) - throw new PipelineException(PipelineFailure.BadAuthentication, details.OriginalException) - { - Response = response, - ApiCall = details - }; + throw new PipelineException(PipelineFailure.BadAuthentication, details.OriginalException) { Response = response, ApiCall = details }; } private void LazyAuditable(AuditEvent e, Node n) { - using (new Auditable(e, AuditTrail, _dateTimeProvider, n)) - { } + using (new Auditable(e, AuditTrail, _dateTimeProvider, n)) { } } - private RequestData CreateSniffRequestData(Node node) => new(HttpMethod.GET, SniffPath, null, _settings, SniffParameters, _memoryStreamFactory) - { - Node = node - }; + private RequestData CreateSniffRequestData(Node node) => + new(HttpMethod.GET, SniffPath, null, _settings, SniffParameters, _memoryStreamFactory) { Node = node }; protected virtual void Dispose() { } } diff --git a/tests/Tests/ClientConcepts/ProductCheckTests.cs b/tests/Tests/ClientConcepts/ProductCheckTests.cs index efac177cd32..2a410320218 100644 --- a/tests/Tests/ClientConcepts/ProductCheckTests.cs +++ b/tests/Tests/ClientConcepts/ProductCheckTests.cs @@ -14,7 +14,7 @@ namespace Tests.ClientConcepts { public class ProductCheckTests { - [U] public void MissingProductNameCausesException() + [U] public void MissingProductNameHeaderCausesExceptionOnVersion7_14() { var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); productCheckResponse.Headers.Clear(); @@ -28,11 +28,11 @@ [U] public void MissingProductNameCausesException() .Throw(); } - [U] public void InvalidProductNameCausesException() + [U] public void InvalidProductNameHeaderCausesException() { var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); productCheckResponse.Headers.Clear(); - productCheckResponse.Headers.Add("X-elastic-product", new List{ "Something Unexpected" }); + productCheckResponse.Headers.Add("X-elastic-product", new List { "Something Unexpected" }); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -45,7 +45,7 @@ [U] public void InvalidProductNameCausesException() [U] public void UnauthorizedStatusCodeFromRootPathDoesNotThrowException_WithExpectedDataOnApiCall() { - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + var productCheckResponse = ValidResponseFor713(); productCheckResponse.StatusCode = (int)HttpStatusCode.Unauthorized; var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); @@ -61,7 +61,7 @@ [U] public void UnauthorizedStatusCodeFromRootPathDoesNotThrowException_WithExpe [U] public void ForbiddenStatusCodeFromRootPathDoesNotThrowException_WithExpectedDataOnApiCall() { - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); + var productCheckResponse = ValidResponseFor713(); productCheckResponse.StatusCode = (int)HttpStatusCode.Forbidden; var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); @@ -77,21 +77,9 @@ [U] public void ForbiddenStatusCodeFromRootPathDoesNotThrowException_WithExpecte [U] public void OldVersionsThrowException() { - var responseJson = new - { - version = new - { - number = "5.9.999", - build_flavor = "default", - }, - tagline = "You Know, for Search" - }; + var responseJson = new { version = new { number = "5.9.999", build_flavor = "default", }, tagline = "You Know, for Search" }; - using var ms = RecyclableMemoryStreamFactory.Default.Create(); - LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); - - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); + var productCheckResponse = CreateResponseWithNoHeader(responseJson); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -104,20 +92,9 @@ [U] public void OldVersionsThrowException() [U] public void MissingTaglineOnVersionSixThrowsException() { - var responseJson = new - { - version = new - { - number = "6.10.0", - build_flavor = "default" - } - }; + var responseJson = new { version = new { number = "6.10.0", build_flavor = "default" } }; - using var ms = RecyclableMemoryStreamFactory.Default.Create(); - LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); - - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); + var productCheckResponse = CreateResponseWithNoHeader(responseJson); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -130,21 +107,9 @@ [U] public void MissingTaglineOnVersionSixThrowsException() [U] public void InvalidTaglineOnVersionSixThrowsException() { - var responseJson = new - { - version = new - { - number = "6.10.0", - build_flavor = "default" - }, - tagline = "unexpected" - }; + var responseJson = new { version = new { number = "6.10.0", build_flavor = "default" }, tagline = "unexpected" }; - using var ms = RecyclableMemoryStreamFactory.Default.Create(); - LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); - - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); + var productCheckResponse = CreateResponseWithNoHeader(responseJson); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -157,21 +122,9 @@ [U] public void InvalidTaglineOnVersionSixThrowsException() [U] public void ExpectedTaglineOnVersionSixDoesNotThrowException_WithExpectedDataOnApiCall() { - var responseJson = new - { - version = new - { - number = "6.8.0", - build_flavor = "default" - }, - tagline = "You Know, for Search" - }; + var responseJson = new { version = new { number = "6.8.0", build_flavor = "default" }, tagline = "You Know, for Search" }; - using var ms = RecyclableMemoryStreamFactory.Default.Create(); - LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); - - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); + var productCheckResponse = CreateResponseWithNoHeader(responseJson); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -185,20 +138,9 @@ [U] public void ExpectedTaglineOnVersionSixDoesNotThrowException_WithExpectedDat [U] public void MissingBuildFlavorOnVersionSevenThrowsException() { - var responseJson = new - { - version = new - { - number = "7.13.0" - }, - tagline = "You Know, for Search" - }; - - using var ms = RecyclableMemoryStreamFactory.Default.Create(); - LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); + var responseJson = new { version = new { number = "7.13.0" }, tagline = "You Know, for Search" }; - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); + var productCheckResponse = CreateResponseWithNoHeader(responseJson); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -211,21 +153,9 @@ [U] public void MissingBuildFlavorOnVersionSevenThrowsException() [U] public void InvalidBuildFlavorMissingOnVersionSevenThrowsException() { - var responseJson = new - { - version = new - { - number = "7.13.0", - build_flavor = "unexpected" - }, - tagline = "You Know, for Search" - }; + var responseJson = new { version = new { number = "7.13.0", build_flavor = "unexpected" }, tagline = "You Know, for Search" }; - using var ms = RecyclableMemoryStreamFactory.Default.Create(); - LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); - - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); + var productCheckResponse = CreateResponseWithNoHeader(responseJson); var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); @@ -237,13 +167,35 @@ [U] public void InvalidBuildFlavorMissingOnVersionSevenThrowsException() } [U] public void ExpectedBuildFlavorOnVersionSixDoesNotThrowException_WithExpectedDataOnApiCall() + { + var responseJson = new { version = new { number = "7.13.0", build_flavor = "default" }, tagline = "You Know, for Search" }; + + var productCheckResponse = CreateResponseWithNoHeader(responseJson); + + var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); + var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); + var client = new ElasticClient(connectionSettings); + + var response = client.Cluster.Health(); + + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckOnStartup); + response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckSuccess); + } + + private static InMemoryHttpResponse ValidResponseFor713() { var responseJson = new { + name = "es01", + cluster_name = "elasticsearch-test-cluster", version = new { - number = "7.13.0", - build_flavor = "default" + number = "7.13.2", + build_flavor = "default", + build_hash = "af1dc6d8099487755c3143c931665b709de3c764", + build_timestamp = "2020-08-11T21:36:48.204330Z", + build_snapshot = false, + lucene_version = "8.6.0" }, tagline = "You Know, for Search" }; @@ -251,17 +203,15 @@ [U] public void ExpectedBuildFlavorOnVersionSixDoesNotThrowException_WithExpecte using var ms = RecyclableMemoryStreamFactory.Default.Create(); LowLevelRequestResponseSerializer.Instance.Serialize(responseJson, ms); - var productCheckResponse = InMemoryConnection.ValidProductCheckResponse(); - productCheckResponse.ResponseBytes = ms.ToArray(); - - var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); - var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection(productCheckResponse)); - var client = new ElasticClient(connectionSettings); + return new InMemoryHttpResponse { ResponseBytes = ms.ToArray() }; + } - var response = client.Cluster.Health(); + private static InMemoryHttpResponse CreateResponseWithNoHeader(object json) + { + using var ms = RecyclableMemoryStreamFactory.Default.Create(); + LowLevelRequestResponseSerializer.Instance.Serialize(json, ms); - response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckOnStartup); - response.ApiCall.AuditTrail.Should().Contain(x => x.Event == AuditEvent.ProductCheckSuccess); + return new InMemoryHttpResponse { ResponseBytes = ms.ToArray() }; } } } From 7181fe71d5f2405b2634736185e6d855be099888 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 22 Jun 2021 15:51:41 +0100 Subject: [PATCH 23/25] Update messages and documentation --- .../Transport/Pipeline/InvalidProductException.cs | 14 ++++++++++++++ .../Transport/Pipeline/PipelineException.cs | 10 ---------- .../Transport/Pipeline/RequestPipeline.cs | 2 +- .../ProductChecking/ProductCheckAtStartup.doc.cs | 9 +++++---- 4 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs diff --git a/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs b/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs new file mode 100644 index 00000000000..c47c0e49731 --- /dev/null +++ b/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs @@ -0,0 +1,14 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; + +namespace Elasticsearch.Net +{ + public class InvalidProductException : Exception + { + public InvalidProductException() + : base(@"The client noticed that the server is not Elasticsearch and we do not support this unknown product.") { } + } +} diff --git a/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs b/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs index 28c91559235..9d6b2507194 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/PipelineException.cs @@ -6,16 +6,6 @@ namespace Elasticsearch.Net { - public class InvalidProductException : Exception - { - public InvalidProductException() - : base(@"TODO: This client is designed to work with the official Elasticsearch product... - -Why are you seeing this error? ------------------------------- -TODO") { } - } - public class PipelineException : Exception { public PipelineException(PipelineFailure failure) diff --git a/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs b/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs index 0d717f51ff6..6ad35894507 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs @@ -24,7 +24,7 @@ public class RequestPipeline : IRequestPipeline private const string NoNodesAttemptedMessage = "No nodes were attempted, this can happen when a node predicate does not match any nodes"; public const string UndeterminedProductWarning = - "TODO: The client could not determine if the server is running the official Elasticsearch product."; + "The client is unable to verify that the server is Elasticsearch due security privileges on the server side."; private static readonly Version MinVersion = new(6, 0, 0); private static readonly Version Version7 = new(7, 0, 0); diff --git a/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs b/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs index 8325929c9d1..62dfed42283 100644 --- a/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs +++ b/tests/Tests/ClientConcepts/ConnectionPooling/ProductChecking/ProductCheckAtStartup.doc.cs @@ -17,10 +17,11 @@ public class ProductCheckAtStartup * == Product check at startup * * Since v7.14.0, the client performs a required product check before the first call. - * This pre-flight product check allows us to establish the version of Elasticsearch we are communicating with. + * This pre-flight product check allows the client to establish the version of Elasticsearch that it is communicating with. * - * The product check requires one additional HTTP request to be sent to the server as part of the request pipeline. - * Once the product check succeeds, no further product check HTTP requests are sent as part of the pipeline. + * The product check requires one additional HTTP request to be sent to the server as part of the request pipeline before + * the main API call is sent. In most cases, this will succeed during the very first API call that the client sends. + * Once the product check succeeds, no further product check HTTP requests are sent for subsequent API calls. */ [U] public async Task ProductCheckPerformedOnlyOnFirstCallWhenSuccessful() { @@ -64,7 +65,7 @@ public async Task ProductCheckPerformedOnSecondCallWhenFirstCheckFails() }, new ClientCall() { { ProductCheckOnStartup }, - { ProductCheckSuccess, 9200 }, // <3> as the previous product check failed, it runs on the second call + { ProductCheckSuccess, 9200 }, // <3> as the previous product check failed, it runs again on the second call { HealthyResponse, 9200 } }, new ClientCall() { From 5bd14822491f293b2b2ed2b836d540b9c245c344 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 22 Jun 2021 15:54:23 +0100 Subject: [PATCH 24/25] Generate asciidoc --- .../product-checking/product-check-at-startup.asciidoc | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc b/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc index 2b2066a89aa..5fca558c7d6 100644 --- a/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc +++ b/docs/client-concepts/connection-pooling/product-checking/product-check-at-startup.asciidoc @@ -16,10 +16,11 @@ please modify the original csharp file found at the link and submit the PR with == Product check at startup Since v7.14.0, the client performs a required product check before the first call. -This pre-flight product check allows us to establish the version of Elasticsearch we are communicating with. +This pre-flight product check allows the client to establish the version of Elasticsearch that it is communicating with. -The product check requires one additional HTTP request to be sent to the server as part of the request pipeline. -Once the product check succeeds, no further product check HTTP requests are sent as part of the pipeline. +The product check requires one additional HTTP request to be sent to the server as part of the request pipeline before +the main API call is sent. In most cases, this will succeed during the very first API call that the client sends. +Once the product check succeeds, no further product check HTTP requests are sent for subsequent API calls. [source,csharp] ---- @@ -76,7 +77,7 @@ audit = await audit.TraceCalls(skipProductCheck: false, ---- <1> as this is the first call, the product check is executed, but fails <2> the actual request is still sent and succeeds -<3> as the previous product check failed, it runs on the second call +<3> as the previous product check failed, it runs again on the second call <4> subsequent calls no longer perform product check Here's an example with a three node cluster which fails for some reason during the first and second product check attempts. From 7a7a3d62a490ef0c5c929b31865bfaf67beb1167 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Tue, 22 Jun 2021 15:56:48 +0100 Subject: [PATCH 25/25] Fix BOM --- .../Transport/Pipeline/InvalidProductException.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs b/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs index c47c0e49731..48ea22ec8dd 100644 --- a/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs +++ b/src/Elasticsearch.Net/Transport/Pipeline/InvalidProductException.cs @@ -1,4 +1,4 @@ -// Licensed to Elasticsearch B.V under one or more agreements. +// Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information