Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ClientModel: Add async and sync enumerable client results #43392

Closed

Conversation

annelo-msft
Copy link
Member

@annelo-msft annelo-msft commented Apr 12, 2024

Draft design proposal for discussion for AsyncEnumerableResult<T> and EnumerableClientResult<T> types to add to System.ClientModel.

Based on requirements from the OpenAI clients to support streaming endpoints for chat and assistants APIs, we have the need to be able to return a result from clients' service methods that represents a collection of values returned via the response's ContentStream.

In .NET, this is best modeled as an async stream, and this means such a ClientResult type in System.ClientModel should implement the IAsyncEnumerable interface. Since System.ClientModel clients also support synchronous service methods, we will provide a synchronous counterpart to the asynchronous that implements IEnumerable.

@tg-msft noted that these "streaming client results" look a lot like Azure.Core's AsyncPageable<T> and Pageable<T> types, and it is true that to the extent that are simply abstractions that represent collections of values, where streaming vs. polling is an implementation detail.

There are discussion points in the comments of this PR noting that we could use a GetRawResponses method in place of a GetRawResponse method to support implementations that use multiple responses to return subsets of elements in the collection.

Addresses:

namespace System.ClientModel;

#pragma warning disable CS1591 // public XML comments
public abstract class AsyncEnumerableResult<T> : ClientResult, IAsyncEnumerable<T>, IDisposable
Copy link
Member Author

@annelo-msft annelo-msft Apr 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inheriting from ClientResult adds the GetRawResponse method to this type. For enumerable client result implementations that use a polling mechanism and therefore have multiple responses associated with them, we might need to either say "GetRawResponse returns the first response and subtypes must expose other public APIs to access subsequent responses," or possibly expose a GetRawResponses method that returns a collection of PipelineResponse values. The latter is not a great Dev UX in the streaming response case where there is only a single response returned, but the former could require us to add additional types to support polling-enumerable scenarios such as paging.


#pragma warning disable CS1591 // public XML comments
public abstract class AsyncEnumerableResult<T> : ClientResult, IAsyncEnumerable<T>, IDisposable
where T : IPersistableModel<T>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another question is whether or not we want to add this type constraint. The benefit is that it could simplify implementations by allowing them to use ModelReaderWriter to deserialize values coming across the wire, rather than requiring them to use other ways to pass in bespoke deserialization routines. A drawback is that it limits the use of the type to models that implement the interface -- if we wanted to enable scenarios like pages of primitives, that could be a problem.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we did not constrain ClientResult<T>, maybe we should keep not constraining. Though in retrospect, maybe we should have.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The libraries haven't GA'ed yet, so we could still constrain it if we feel strongly about it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, but not if we want Azure.Core Pageable<T> to be able to inherit from it. Alas! I will remove it.

@@ -40,6 +48,14 @@ public partial class ClientResult<T> : System.ClientModel.ClientResult
public virtual T Value { get { throw null; } }
public static implicit operator T (System.ClientModel.ClientResult<T> result) { throw null; }
}
public abstract partial class EnumerableClientResult<T> : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable<T>, System.Collections.IEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names don't seem symetrical. What about the following:

AsyncEnumerableResult
EnumerableResult
//or
ClientResults
ClientResultsAsync

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually not wild about ClientResults because I think it complicates what we're defining a "result" to be (although I am open to debate here).

To take a concrete example, Pageable<T> is a collection of T's. Does the "result" mean the T or with the response? In a simple ClientResult<T> case there's a 1-1 relationship between them. But ClientResult itself just exposes GetRawResponse so it seems like result maps more closely to the response than the T. Given that, it feels strange to me to make the "result" plural when the T is the plural thing and there might only ever be one response.

Let me think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like 3.5.2 in the FDG recommends using Collection in the name of types that derive from IEnumerable<T> ... what about ClientResultCollection<T> and AsyncClientResultCollection<T>? Those are getting long, though, and don't really address my question about whether it is a collection of results, but I think I'm willing to drop argument since we defined ClientResult in the refdocs as "the result of a cloud service operation." A sequence of such results could easily be a result collection. Is it too long?


#pragma warning disable CS1591 // public XML comments
public abstract class AsyncEnumerableResult<T> : ClientResult, IAsyncEnumerable<T>, IDisposable
where T : IPersistableModel<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we did not constrain ClientResult<T>, maybe we should keep not constraining. Though in retrospect, maybe we should have.

public abstract class AsyncEnumerableResult<T> : ClientResult, IAsyncEnumerable<T>, IAsyncDisposable
where T : IPersistableModel<T>
{
protected internal AsyncEnumerableResult(PipelineResponse response) : base(response)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can the response be updated/changed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it can. Is there a scenario where we would want that?
(Not sure I understand the implications of your question.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scenario is using this type to expose pageable REST APIs. In such scenarios there are multiple responses representing all items in the collection/pageable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you want to do that by changing the value returned from the response? Why not return a collection of PipelineResponses instead? (i.e. not inherit from ClientResult)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. I see that we already do that in Azure.Core Operation implementations such as KeyVaultBackupOperation and FuzzySearchBatchOperation.


public abstract IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);

protected abstract ValueTask DisposeAsyncCore();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FDGs say to make it virtual

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed per discussion about moving disposal of resources into the enumerator.


protected abstract ValueTask DisposeAsyncCore();

protected abstract void Dispose(bool disposing);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this for? i.e. who call it? BTW, FDGs (that Jeremy wrote) say to implement both IAsyncDisposabel and IDisposable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed per discussion about moving disposal of resources into the enumerator.

@azure-sdk
Copy link
Collaborator

API change check

APIView has identified API level changes in this PR and created following API reviews.

System.ClientModel

@annelo-msft
Copy link
Member Author

Closing in favor of #43840

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants