From c5b3a36443807eb5af2ce6e02e580176d9f030cb Mon Sep 17 00:00:00 2001 From: Emilien Bevierre Date: Tue, 7 Oct 2025 11:10:10 +0200 Subject: [PATCH 1/2] Allow users to specify timespan unit in connection string parameters --- .../Internal/ConnectionString.cs | 68 ++++++++++++++++++- .../Internal/ConnectionStringTests.cs | 21 ++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Couchbase.Analytics/Internal/ConnectionString.cs b/src/Couchbase.Analytics/Internal/ConnectionString.cs index b01cc07..ac03ec1 100644 --- a/src/Couchbase.Analytics/Internal/ConnectionString.cs +++ b/src/Couchbase.Analytics/Internal/ConnectionString.cs @@ -214,7 +214,73 @@ public bool TryGetParameter(string key, out TimeSpan parameter) { if (TryGetParameter(key, out string value)) { - parameter = TimeSpan.FromMilliseconds(Convert.ToUInt32(value)); + var trimmed = value.Trim(); + if (trimmed.Length == 0) + { + parameter = default; + return false; + } + + // Parse optional unit suffix: us, ms, s, m, h (case-insensitive) + // Default unit is milliseconds if no suffix is provided + var lower = trimmed.ToLowerInvariant(); + + // Determine unit and numeric portion (check longest suffixes first) + string unit; + string numericPortion; + if (lower.EndsWith("us")) + { + unit = "us"; + numericPortion = trimmed[..^2].Trim(); + } + else if (lower.EndsWith("ms")) + { + unit = "ms"; + numericPortion = trimmed[..^2].Trim(); + } + else if (lower.EndsWith("s")) + { + unit = "s"; + numericPortion = trimmed[..^1].Trim(); + } + else if (lower.EndsWith("m")) + { + unit = "m"; + numericPortion = trimmed[..^1].Trim(); + } + else if (lower.EndsWith("h")) + { + unit = "h"; + numericPortion = trimmed[..^1].Trim(); + } + else + { + unit = "ms"; + numericPortion = trimmed; + } + + if (numericPortion.Length == 0) + { + parameter = TimeSpan.Zero; + return false; + } + + // Allow integer or decimal values + if (!double.TryParse(numericPortion, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var amount)) + { + parameter = TimeSpan.Zero; + throw new FormatException($"Invalid numeric value for parameter '{key}': '{value}'"); + } + + parameter = unit switch + { + "us" => TimeSpan.FromMicroseconds(amount), + "ms" => TimeSpan.FromMilliseconds(amount), + "s" => TimeSpan.FromSeconds(amount), + "m" => TimeSpan.FromMinutes(amount), + "h" => TimeSpan.FromHours(amount), + _ => TimeSpan.FromMilliseconds(amount) + }; return true; } diff --git a/tests/Couchbase.Analytics.UnitTests/Internal/ConnectionStringTests.cs b/tests/Couchbase.Analytics.UnitTests/Internal/ConnectionStringTests.cs index 7109520..9fd3b7d 100644 --- a/tests/Couchbase.Analytics.UnitTests/Internal/ConnectionStringTests.cs +++ b/tests/Couchbase.Analytics.UnitTests/Internal/ConnectionStringTests.cs @@ -301,6 +301,27 @@ public void Test_ConnectionString_TimeoutParameter_QueryTimeout_Values(string ti Assert.Equal(TimeSpan.FromMilliseconds(expectedMilliseconds), options.TimeoutOptions.QueryTimeout); } + [Theory] + [InlineData("500us", 5_000L)] // 500 microseconds -> 5000 ticks + [InlineData("250ms", 2_500_000L)] // 250 milliseconds -> 2,500,000 ticks + [InlineData("30s", 300_000_000L)] // 30 seconds -> 300,000,000 ticks + [InlineData("2m", 1_200_000_000L)] // 2 minutes -> 1,200,000,000 ticks + [InlineData("1h", 36_000_000_000L)] // 1 hour -> 36,000,000,000 ticks + [InlineData("30S", 300_000_000L)] // case-insensitive + [InlineData("1.5s", 15_000_000L)] // fractional seconds + [InlineData("0s", 0L)] // zero value + public void Test_ConnectionString_TimeoutParameter_ConnectTimeout_WithUnits(string timeoutValue, long expectedTicks) + { + var connectionString = $"http://localhost:8095?timeout.connect_timeout={timeoutValue}"; + + var options = new ClusterOptions + { + ConnectionString = connectionString + }; + + Assert.Equal(expectedTicks, options.TimeoutOptions.ConnectTimeout.Ticks); + } + [Theory] [InlineData("true", true)] [InlineData("false", false)] From f681223ef8072672b06c3e26a4801a07e7411095 Mon Sep 17 00:00:00 2001 From: Emilien Bevierre Date: Tue, 7 Oct 2025 11:24:27 +0200 Subject: [PATCH 2/2] Expose IDeserializer in ClusterOptions --- .../Internal/AnalyticsService.cs | 7 +++++-- .../Options/ClusterOptions.cs | 18 +++++++++++++++--- .../Options/QueryOptions.cs | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/Couchbase.Analytics/Internal/AnalyticsService.cs b/src/Couchbase.Analytics/Internal/AnalyticsService.cs index 36978bf..c9dba2e 100644 --- a/src/Couchbase.Analytics/Internal/AnalyticsService.cs +++ b/src/Couchbase.Analytics/Internal/AnalyticsService.cs @@ -66,7 +66,7 @@ private async Task ExecuteQueryAsync(StringContent content, HttpCl try { - var response = await httpClient.SendAsync(request, + var response = await httpClient.SendAsync(request, asStreaming ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead, cancellationToken) .ConfigureAwait(false); @@ -105,6 +105,9 @@ private async Task ExecuteWithRetryAsync(string statement, QueryOp var timeout = options.Timeout ?? _clusterOptions.TimeoutOptions.QueryTimeout; options = options with { Timeout = timeout }; + // The QueryOptions' Deserializer should override the ClusterOptions'. + var deserializer = options.Deserializer ?? _clusterOptions.Deserializer; + var errorContext = new ErrorContext(options.ClientContextId, stopwatch, timeout); Exception? lastException = null; @@ -134,7 +137,7 @@ private async Task ExecuteWithRetryAsync(string statement, QueryOp "Analytics query attempt {Attempt} starting for {ClientContextId} (elapsed: {Elapsed}ms)", attempt + 1, options.ClientContextId, stopwatch.Elapsed.TotalMilliseconds); - var result = await ExecuteQueryAsync(content, httpClient, options.AsStreaming, options.Deserializer, errorContext, cancellationToken).ConfigureAwait(false); + var result = await ExecuteQueryAsync(content, httpClient, options.AsStreaming, deserializer, errorContext, cancellationToken).ConfigureAwait(false); // Always read errors from the result if (result.Errors is { Count: > 0 }) diff --git a/src/Couchbase.Analytics/Options/ClusterOptions.cs b/src/Couchbase.Analytics/Options/ClusterOptions.cs index 465cfcc..a89e710 100644 --- a/src/Couchbase.Analytics/Options/ClusterOptions.cs +++ b/src/Couchbase.Analytics/Options/ClusterOptions.cs @@ -26,6 +26,7 @@ using Couchbase.AnalyticsClient.Internal; using Couchbase.AnalyticsClient.Internal.DI; using Couchbase.AnalyticsClient.Internal.Utils; +using Couchbase.Core.Json; using Couchbase.Core.Utils; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -41,6 +42,8 @@ public record ClusterOptions [InterfaceStability(StabilityLevel.Volatile)] public uint MaxRetries { get; private set; } = 7; + public IDeserializer Deserializer { get; private set; } = new StjJsonDeserializer(); + internal ConnectionString? ConnectionStringValue { get; private set; } private ILoggerFactory? Logging { get; set; } @@ -78,13 +81,24 @@ public ClusterOptions WithMaxRetries(uint maxRetries) /// /// The logger factory. /// - /// A reference to this object for method chaining. + /// A copy of this object for method chaining. /// public ClusterOptions WithLogging(ILoggerFactory? loggerFactory = null) { return this with { Logging = loggerFactory }; } + /// + /// Sets the to use for deserializing JSON responses. + /// This can be overridden on a per-operation basis by passing a deserializer to the . + /// + /// An implementation of + /// A copy of this object for method chaining. + public ClusterOptions WithDeserializer(IDeserializer deserializer) + { + return this with { Deserializer = deserializer }; + } + private readonly IDictionary _services = DefaultServices.GetDefaultServices(); internal ICouchbaseServiceProvider BuildServiceProvider(ICredential? credential = null) @@ -172,6 +186,4 @@ internal string? ConnectionString } } } - - //internal JsonSerializer Serializer { get; init; } //static } \ No newline at end of file diff --git a/src/Couchbase.Analytics/Options/QueryOptions.cs b/src/Couchbase.Analytics/Options/QueryOptions.cs index cdeb72c..26939cd 100644 --- a/src/Couchbase.Analytics/Options/QueryOptions.cs +++ b/src/Couchbase.Analytics/Options/QueryOptions.cs @@ -67,7 +67,7 @@ public record QueryOptions /// Used to deserialize query rows. /// Default to /// - public IDeserializer Deserializer { get; init; } = new StjJsonDeserializer(); + public IDeserializer? Deserializer { get; init; } /// /// Whether the query is read-only.