diff --git a/sdk/core/System.ClientModel/CHANGELOG.md b/sdk/core/System.ClientModel/CHANGELOG.md index c6622983936e..a7e519dc92d4 100644 --- a/sdk/core/System.ClientModel/CHANGELOG.md +++ b/sdk/core/System.ClientModel/CHANGELOG.md @@ -13,6 +13,7 @@ ### Other Changes - Removed `ReturnWhen` enum in favor of using bool `waitUntilCompleted` parameter in third-party client LRO method signatures. +- Added abstract `UpdateStatus` method to `OperationResult`. ## 1.1.0-beta.6 (2024-08-01) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index c6c50b095782..38a53857153d 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -193,10 +193,12 @@ public ModelReaderWriterOptions(string format) { } public abstract partial class OperationResult : System.ClientModel.ClientResult { protected OperationResult(System.ClientModel.Primitives.PipelineResponse response) { } - public abstract bool IsCompleted { get; protected set; } + public bool HasCompleted { get { throw null; } protected set { } } public abstract System.ClientModel.ContinuationToken? RehydrationToken { get; protected set; } - public abstract void WaitForCompletion(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - public abstract System.Threading.Tasks.Task WaitForCompletionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + public abstract System.ClientModel.ClientResult UpdateStatus(System.ClientModel.Primitives.RequestOptions? options = null); + public abstract System.Threading.Tasks.ValueTask UpdateStatusAsync(System.ClientModel.Primitives.RequestOptions? options = null); + public virtual void WaitForCompletion(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { } + public virtual System.Threading.Tasks.ValueTask WaitForCompletionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } [System.AttributeUsageAttribute(System.AttributeTargets.Class)] public sealed partial class PersistableModelProxyAttribute : System.Attribute diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 774d6dc40167..2a716c72f50d 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -192,10 +192,12 @@ public ModelReaderWriterOptions(string format) { } public abstract partial class OperationResult : System.ClientModel.ClientResult { protected OperationResult(System.ClientModel.Primitives.PipelineResponse response) { } - public abstract bool IsCompleted { get; protected set; } + public bool HasCompleted { get { throw null; } protected set { } } public abstract System.ClientModel.ContinuationToken? RehydrationToken { get; protected set; } - public abstract void WaitForCompletion(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - public abstract System.Threading.Tasks.Task WaitForCompletionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + public abstract System.ClientModel.ClientResult UpdateStatus(System.ClientModel.Primitives.RequestOptions? options = null); + public abstract System.Threading.Tasks.ValueTask UpdateStatusAsync(System.ClientModel.Primitives.RequestOptions? options = null); + public virtual void WaitForCompletion(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { } + public virtual System.Threading.Tasks.ValueTask WaitForCompletionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } [System.AttributeUsageAttribute(System.AttributeTargets.Class)] public sealed partial class PersistableModelProxyAttribute : System.Attribute diff --git a/sdk/core/System.ClientModel/src/Convenience/OperationResult.cs b/sdk/core/System.ClientModel/src/Convenience/OperationResult.cs index 2fcf9e1e3cd0..facf771dc803 100644 --- a/sdk/core/System.ClientModel/src/Convenience/OperationResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/OperationResult.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.ClientModel.Internal; using System.Threading; using System.Threading.Tasks; @@ -34,44 +35,128 @@ protected OperationResult(PipelineResponse response) /// Gets a value that indicates whether the operation has completed. /// /// true if the operation has reached a terminal state - /// (that is, is has finished successfully, ended due to an error condition, + /// (that is, it has finished successfully, ended due to an error condition, /// or has been cancelled by a user); otherwise, false. /// - public abstract bool IsCompleted { get; protected set; } + /// is updated by the + /// method, based on the response received from + /// the service regarding the operation's status. Users must call + /// , , or other + /// method provided by the derived type to ensure that the value of the + /// property reflects the current status of the + /// operation running on the service. + /// + public bool HasCompleted { get; protected set; } /// /// Gets a token that can be used to rehydrate the operation. /// /// A token that can be used to rehydrate the operation, for example /// to monitor its progress or to obtain its final result, from a process - /// different thatn the one that started the operation. + /// different than the one that started the operation. public abstract ContinuationToken? RehydrationToken { get; protected set; } + /// + /// Sends a request to the service to get the current status of the + /// operation and updates and other relevant + /// properties. + /// + /// The to be used when + /// sending the request to the service. + /// The returned from the service call. + /// + /// This method updates the value returned from + /// and will update + /// to true once the operation has finished + /// running on the service. It will also update Value or + /// Status properties if present on the + /// derived type. + public abstract ValueTask UpdateStatusAsync(RequestOptions? options = default); + + /// + /// Sends a request to the service to get the current status of the + /// operation and updates and other relevant + /// properties. + /// + /// The to be used when + /// sending the request to the service. + /// The returned from the service call. + /// + /// This method updates the value returned from + /// and will update + /// to true once the operation has finished + /// running on the service. It will also update Value or + /// Status properties if present on the + /// derived type. + public abstract ClientResult UpdateStatus(RequestOptions? options = default); + /// /// Waits for the operation to complete processing on the service. /// - /// Derived types may implement - /// using different mechanisms to obtain updates from the service regarding - /// the progress of the operation. If the derived type polls for status - /// updates, it provides overloads of + /// Derived types may override + /// to implement different mechanisms for obtaining updates from the service + /// regarding the progress of the operation. For example, if the derived + /// type polls for status updates, it may provides overloads of + /// /// that allow the caller to specify the polling interval or delay strategy - /// used to wait between sending request for updates. + /// used to wait between sending request for updates. By default, + /// waits a default interval between + /// calling to obtain a status updates, so + /// if updates are delivered via streaming or another mechanism where a wait + /// time is not required, derived types can override this method to update + /// the status more frequently. /// /// The /// was cancelled. - public abstract Task WaitForCompletionAsync(CancellationToken cancellationToken = default); + public virtual async ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) + { + PollingInterval pollingInterval = new(); + + while (!HasCompleted) + { + PipelineResponse response = GetRawResponse(); + + await pollingInterval.WaitAsync(response, cancellationToken).ConfigureAwait(false); + + RequestOptions? options = RequestOptions.FromCancellationToken(cancellationToken); + ClientResult result = await UpdateStatusAsync(options).ConfigureAwait(false); + + SetRawResponse(result.GetRawResponse()); + } + } /// /// Waits for the operation to complete processing on the service. /// - /// Derived types may implement - /// using different mechanisms to obtain updates from the service regarding - /// the progress of the operation. If the derived type polls for status - /// updates, it provides overloads of + /// Derived types may override + /// to implement different mechanisms for obtaining updates from the service + /// regarding the progress of the operation. For example, if the derived + /// type polls for status updates, it may provides overloads of + /// /// that allow the caller to specify the polling interval or delay strategy - /// used to wait between sending request for updates. + /// used to wait between sending request for updates. By default, + /// waits a default interval between + /// calling to obtain a status updates, so + /// if updates are delivered via streaming or another mechanism where a wait + /// time is not required, derived types can override this method to update + /// the status more frequently. /// /// The /// was cancelled. - public abstract void WaitForCompletion(CancellationToken cancellationToken = default); + public virtual void WaitForCompletion(CancellationToken cancellationToken = default) + { + PollingInterval pollingInterval = new(); + + while (!HasCompleted) + { + PipelineResponse response = GetRawResponse(); + + pollingInterval.Wait(response, cancellationToken); + + RequestOptions? options = RequestOptions.FromCancellationToken(cancellationToken); + ClientResult result = UpdateStatus(options); + + SetRawResponse(result.GetRawResponse()); + } + } } diff --git a/sdk/core/System.ClientModel/src/Internal/PollingInterval.cs b/sdk/core/System.ClientModel/src/Internal/PollingInterval.cs new file mode 100644 index 000000000000..abc212675e6d --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/PollingInterval.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Threading; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal sealed class PollingInterval +{ + private static readonly TimeSpan DefaultDelay = TimeSpan.FromSeconds(1.0); + + private readonly TimeSpan _interval; + + public PollingInterval(TimeSpan? interval = default) + { + _interval = interval ?? DefaultDelay; + } + + public async Task WaitAsync(PipelineResponse response, CancellationToken cancellationToken) + { + TimeSpan delay = GetDelay(response); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + public void Wait(PipelineResponse response, CancellationToken cancellationToken) + { + TimeSpan delay = GetDelay(response); + + if (!cancellationToken.CanBeCanceled) + { + Thread.Sleep(delay); + return; + } + + if (cancellationToken.WaitHandle.WaitOne(delay)) + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + + private TimeSpan GetDelay(PipelineResponse response) + => PipelineResponseHeaders.TryGetRetryAfter(response, out TimeSpan retryAfter) + && retryAfter > _interval ? retryAfter : _interval; +} diff --git a/sdk/core/System.ClientModel/src/Message/PipelineResponseHeaders.cs b/sdk/core/System.ClientModel/src/Message/PipelineResponseHeaders.cs index f9a5a4001ca0..ea066bed5556 100644 --- a/sdk/core/System.ClientModel/src/Message/PipelineResponseHeaders.cs +++ b/sdk/core/System.ClientModel/src/Message/PipelineResponseHeaders.cs @@ -11,6 +11,8 @@ namespace System.ClientModel.Primitives; /// public abstract class PipelineResponseHeaders : IEnumerable> { + private const string RetryAfterHeaderName = "Retry-After"; + /// /// Attempts to retrieve the value associated with the specified header /// name. @@ -37,4 +39,26 @@ public abstract class PipelineResponseHeaders : IEnumerable> GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + internal static bool TryGetRetryAfter(PipelineResponse response, out TimeSpan value) + { + // See: https://www.rfc-editor.org/rfc/rfc7231#section-7.1.3 + if (response.Headers.TryGetValue(RetryAfterHeaderName, out string? retryAfter)) + { + if (int.TryParse(retryAfter, out var delaySeconds)) + { + value = TimeSpan.FromSeconds(delaySeconds); + return true; + } + + if (DateTimeOffset.TryParse(retryAfter, out DateTimeOffset retryAfterDate)) + { + value = retryAfterDate - DateTimeOffset.Now; + return true; + } + } + + value = default; + return false; + } } diff --git a/sdk/core/System.ClientModel/src/Options/RequestOptions.cs b/sdk/core/System.ClientModel/src/Options/RequestOptions.cs index b12167ee7343..763748fbb101 100644 --- a/sdk/core/System.ClientModel/src/Options/RequestOptions.cs +++ b/sdk/core/System.ClientModel/src/Options/RequestOptions.cs @@ -31,6 +31,19 @@ public RequestOptions() { } + internal static RequestOptions? FromCancellationToken(CancellationToken cancellationToken) + { + if (cancellationToken == default) + { + return null; + } + + return new RequestOptions() + { + CancellationToken = cancellationToken + }; + } + /// /// Gets or sets the used for the duration /// of the call to . diff --git a/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs b/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs index 4ef13af4d451..3e9be2399624 100644 --- a/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs +++ b/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs @@ -24,7 +24,6 @@ public class ClientRetryPolicy : PipelinePolicy private const int DefaultMaxRetries = 3; private static readonly TimeSpan DefaultInitialDelay = TimeSpan.FromSeconds(0.8); - private const string RetryAfterHeaderName = "Retry-After"; private readonly int _maxRetries; private readonly TimeSpan _initialDelay; @@ -276,7 +275,7 @@ protected virtual TimeSpan GetNextDelay(PipelineMessage message, int tryCount) double nextDelayMilliseconds = (1 << (tryCount - 1)) * _initialDelay.TotalMilliseconds; if (message.Response is not null && - TryGetRetryAfter(message.Response, out TimeSpan retryAfter) && + PipelineResponseHeaders.TryGetRetryAfter(message.Response, out TimeSpan retryAfter) && retryAfter.TotalMilliseconds > nextDelayMilliseconds) { return retryAfter; @@ -319,26 +318,4 @@ protected virtual void Wait(TimeSpan time, CancellationToken cancellationToken) CancellationHelper.ThrowIfCancellationRequested(cancellationToken); } } - - private static bool TryGetRetryAfter(PipelineResponse response, out TimeSpan value) - { - // See: https://www.rfc-editor.org/rfc/rfc7231#section-7.1.3 - if (response.Headers.TryGetValue(RetryAfterHeaderName, out string? retryAfter)) - { - if (int.TryParse(retryAfter, out var delaySeconds)) - { - value = TimeSpan.FromSeconds(delaySeconds); - return true; - } - - if (DateTimeOffset.TryParse(retryAfter, out DateTimeOffset retryAfterDate)) - { - value = retryAfterDate - DateTimeOffset.Now; - return true; - } - } - - value = default; - return false; - } } diff --git a/sdk/core/System.ClientModel/tests/Convenience/OperationResultTests.cs b/sdk/core/System.ClientModel/tests/Convenience/OperationResultTests.cs new file mode 100644 index 000000000000..9253826efd18 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/Convenience/OperationResultTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using ClientModel.Tests; +using ClientModel.Tests.Mocks; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Results; + +/// +/// Unit tests for operation results. +/// +public class OperationResultTests : SyncAsyncTestBase +{ + public OperationResultTests(bool isAsync) : base(isAsync) + { + } + + [Test] + public async Task CanWaitForCompletion() + { + int updateCount = 0; + int completeAfterCount = 2; + MockPipelineResponse initialResponse = new(200); + + MockOperationResult operation = new(initialResponse, completeAfterCount); + operation.OnUpdate = () => updateCount++; + + if (IsAsync) + { + await operation.WaitForCompletionAsync(); + } + else + { + operation.WaitForCompletion(); + } + + Assert.AreEqual(updateCount, completeAfterCount); + Assert.IsTrue(operation.HasCompleted); + Assert.AreNotEqual(operation.GetRawResponse(), initialResponse); + } + + [Test] + public async Task CanPollWithCustomInterval() + { + int updateCount = 0; + int completeAfterCount = 2; + MockPipelineResponse initialResponse = new(200); + + MockOperationResult operation = new(initialResponse, completeAfterCount); + operation.OnUpdate = () => updateCount++; + + while (!operation.HasCompleted) + { + PipelineResponse priorResponse = operation.GetRawResponse(); + + ClientResult result = IsAsync ? + await operation.UpdateStatusAsync() : + operation.UpdateStatus(); + + // Custom interval: no wait. + + Assert.AreNotEqual(operation.GetRawResponse(), priorResponse); + } + + Assert.AreEqual(updateCount, completeAfterCount); + Assert.IsTrue(operation.HasCompleted); + } + + [Test] + public void CanCancelWaitForCompletion() + { + int updateCount = 0; + int completeAfterCount = 2; + MockPipelineResponse initialResponse = new(200); + + MockOperationResult operation = new(initialResponse, completeAfterCount); + operation.OnUpdate = () => updateCount++; + + using CancellationTokenSource source = new CancellationTokenSource(); + + // Default OperationResult polling interval is 1s. This will cancel + // before first call to UpdateStatus. + source.CancelAfter(100); + + if (IsAsync) + { + Assert.That(async () => await operation.WaitForCompletionAsync(source.Token), + Throws.InstanceOf()); + } + else + { + Assert.That(() => operation.WaitForCompletion(source.Token), + Throws.InstanceOf()); + } + + Assert.IsTrue(source.IsCancellationRequested); + + Assert.AreEqual(updateCount, 0); + Assert.IsFalse(operation.HasCompleted); + } + + [Test] + public async Task WaitForCompletionResponseRetryAfterHeader() + { + int updateCount = 0; + int completeAfterCount = 2; + int retryAfterSeconds = 2; + int defaultWaitSeconds = 1; + + static PipelineResponse GetResponse(int retryAfterSeconds) + { + MockPipelineResponse response = new(200); + response.SetHeader("Retry-After", retryAfterSeconds.ToString()); + return response; + } + + MockOperationResult operation = new(GetResponse(retryAfterSeconds), completeAfterCount); + operation.OnUpdate = () => updateCount++; + operation.GetNextResponse = () => GetResponse(retryAfterSeconds); + + Stopwatch stopwatch = Stopwatch.StartNew(); + + if (IsAsync) + { + await operation.WaitForCompletionAsync(); + } + else + { + operation.WaitForCompletion(); + } + + stopwatch.Stop(); + + Assert.AreEqual(updateCount, completeAfterCount); + Assert.IsTrue(operation.HasCompleted); + + Assert.Greater(stopwatch.Elapsed, TimeSpan.FromSeconds(completeAfterCount * defaultWaitSeconds)); + } +} diff --git a/sdk/core/System.ClientModel/tests/Convenience/PageCollectionScenarioTests.cs b/sdk/core/System.ClientModel/tests/Convenience/PageCollectionScenarioTests.cs index 7efc42d41494..1846346ed408 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/PageCollectionScenarioTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/PageCollectionScenarioTests.cs @@ -9,7 +9,7 @@ using ClientModel.Tests.Paging; using NUnit.Framework; -namespace System.ClientModel.Tests.Paging; +namespace System.ClientModel.Tests.Results; /// /// Scenario tests for sync and async page collections. diff --git a/sdk/core/System.ClientModel/tests/Convenience/PageCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/PageCollectionTests.cs index 4a6a2c640d3b..c29dd035d8e5 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/PageCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/PageCollectionTests.cs @@ -8,7 +8,7 @@ using ClientModel.Tests.Paging; using NUnit.Framework; -namespace System.ClientModel.Tests.Paging; +namespace System.ClientModel.Tests.Results; /// /// Unit tests for sync and async page collections. diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockOperationResult.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockOperationResult.cs new file mode 100644 index 000000000000..979c87016b9b --- /dev/null +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockOperationResult.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Threading.Tasks; + +namespace ClientModel.Tests.Mocks; + +public class MockOperationResult : OperationResult +{ + // Count of responses to compete the operation after. + private readonly int _completeAfterCount; + + private int _updateCount; + + internal MockOperationResult(PipelineResponse response, int completeAfterCount = 2) : base(response) + { + _completeAfterCount = completeAfterCount; + GetNextResponse = () => new MockPipelineResponse(200); + } + + public Action? OnUpdate { get; set; } + + public Func GetNextResponse { get; set; } + + public override ContinuationToken? RehydrationToken { get; protected set; } + + public override ValueTask UpdateStatusAsync(RequestOptions? options = null) + { + ClientResult result = UpdateStatus(options); + + return new ValueTask(result); + } + + public override ClientResult UpdateStatus(RequestOptions? options = null) + { + _updateCount++; + + if (_updateCount >= _completeAfterCount) + { + HasCompleted = true; + } + + PipelineResponse response = GetNextResponse(); + + SetRawResponse(response); + + OnUpdate?.Invoke(); + + return ClientResult.FromResponse(response); + } +}