6

I've been looking around to find an answer to this question, but I can't seem to find any definitive answer. We use OData v4, using ODataQueryOptions.ApplyTo to apply the OData options to a query. We also use ODataQuerySettings to set the pagesize. When we set a page size, we cannot use ToListAsync() anymore on the IQueryable that is returned from ODataQueryOptions.ApplyTo. The error message says that the provider of the IQueryable is no longer from Entity Framework.

I found this is because when using a pagesize, OData resolves the IQueryable by passing it through a TruncatedCollection. This TruncatedCollection retrieves all (pagesize + 1) results from the database to check if there were more than pagesize results. However, ApplyTo is not an async method, so I can safely assume this database query is not performed asynchronously.

Is there something I can do to make sure the query is performed asynchronously? Surely the OData team has thought of this? Or is it fine to keep it synchronous as it is? To me it seems asynchronous IO is nearly a necessity nowadays, as we want our API to scale well and not have all our threads blocked while waiting for IO.

Thanks for the help!

Edit 1:

I was asked to give some code to explain what I mean.

In BaseController.cs:

public class BaseController : ODataController
{
    private static readonly ODataQuerySettings DefaultSettings = new ODataQuerySettings() { PageSize = 60 };


    protected Task<IHttpActionResult> ODataResult<T>(IQueryable<T> query, ODataQueryOptions<T> options)
    {
        IQueryable result = options.ApplyTo(query, DefaultSettings);
        return Task.FromResult(ODataOk(result));
    }
}

In CustomerController.cs:

public class CustomerController : BaseController
{
    ICustomerService customerService;

    public async Task<IHttpActionResult> Get(ODataQueryOptions<Customer> options)
    {
        var query = customerService.Query();
        return await ODataResult(query, options);
    }
}

As I said above though, the issue is in the underlying code of ApplyTo. This is a method from OData itself. The line:

    IQueryable result = options.ApplyTo(query, DefaultSettings);

already executes the database query, due to the fact that we define a pagesize in the DefaultSettings. Defining a pagesize causes the underlying code in ApplyTo to retrieve all the data from the database, and then returns the retrieved list as a queryable. This means the database is queried in a synchronous function.

So, my question is: Is there a way to implement paging into OData without giving up on async reads? Or am I overcomplicating things when attempting to do this?

t.baart
  • 155
  • 2
  • 7
  • Can you provide some code please? from what you explained, I think adding `.AsQueryable()` at the end of your query may solve the problem – sepehr Mar 10 '16 at 16:30
  • Not sure if you're asking for a solution to the `IQueryable .ToListAsync` issue or asking for an async `ODataQueryOptions.ApplyTo`. – lencharest Mar 10 '16 at 18:39
  • Specified my question and provided some of our code. Sadly, AsQueryable() wouldn't work. The data is already retrieved from the database when ApplyTo returns. – t.baart Mar 11 '16 at 09:50
  • I've been stumped with this as well. I think the sticking point is that there is not an IQueryableAsync interface anywhere that can be leveraged. The .ToListAsync() is an IQueryable extension in EF, and I'm sure the OData people didn't want to couple to EF. If only there were a common interface they could share like IQueryableAsync or something else, that would allow a provider to allow async execution, and a consumer to call it. As it is, it seems like our Gets have to be sync, which is unfortunate because we usually get more than Save. – Sean B Nov 14 '16 at 17:21
  • @t.baart, I have examined `ODataQueryOptions.ApplyTo` source code and looks like it should not enumerate `IQueryable` but only build an expression. Anyway, were you able to solve this issue or found an approach to deal with it? – Andrii Litvinov May 24 '17 at 13:55
  • 1
    @AndriiLitvinov It definitely enumerates it when using paging. I know this from when I looked through the source code myself. I did not manage to solve this. The only way to solve it was to write my own implementation of ApplyTo, which I was not willing to spend my time on. We decided to go with the simpler route and simply use the EnableQuery attribute on the controller and let OData do its thing. The resulting SQL queries were far from ideal, but so be it. Performance and scalability isn't a concern for us just yet. – t.baart May 25 '17 at 19:21
  • Did you ever get this figured out? – txavier Nov 01 '18 at 13:45
  • Nope, sorry mate. To make this work you have to apply all the odata query options yourself in custom code. It's not worth it for me, so I didn't bother with that. – t.baart Nov 02 '18 at 14:06

2 Answers2

2

One can implement paging in OData without giving up async reads. Paging basically means applying one more expression to the IQueryable instance. After calling IQueryable.Take, one can call IQueryable.ToListAsync(where enumeration would actually happen).

But Microsoft Web Api OData v4 implementation is such that the query enumeration is happening synchronously. See here. ODataQuerySettings.ApplyTo method is using TruncatedCollection internally. TruncatedCollection is inherited from System.Collections.Generic.List and when being created it passes down IQueryable constructor parameter to List's constructor which accepts IEnumerable, iterates it and copies into internal array.

So, you can fork Web Api OData (since it's open source), tweak it and make it async. Or implement your own version of ODataQuerySettings.ApplyTo which would be async.

Max Kudosh
  • 21
  • 2
  • Yep, I mentioned that TruncatedCollection was the problem in my question. I tried forking, however, that causes some issues along the line. This was over a year ago, so I don't remember exactly, but I know it had something to do with some OData operations that were applied *after* the TruncatedCollection was used to apply paging. Especially the Expand option caused a lot of issues, which caused me to have to write my own visitor pattern and jump through other loops in order to make all of this work. I decided it wasn't worth it. I'll revisit when scalability becomes more of an issue. – t.baart Aug 04 '17 at 15:18
-2

I'm not sure why you are trying to call ToListAsync(). There should be no need. In your action method, you should be composing the query, but not actually fetching any data. All of this should be trivial with no need to be asynchronous. The IQueryable is executed later by the framework. (The framework should also automatically apply all of the OData filter parameters from the query string to the query you have returned including the paging).

In fact, having an asynchronous result of type IQueryable (or IEnumerable for that matter) makes no sense (see this answer). You cannot enumerate asynchronously.

You could in theory get all of the results into an array asynchronously, but then Odata would apply its filters in memory rather than against the query. I'm not sure why you would need to do that, but there is then very little point in using Odata.

Community
  • 1
  • 1
Tim Rogers
  • 21,297
  • 6
  • 52
  • 68
  • 1
    The point isn't that I *want* to use `ToListAsync()`, but it is one way to execute the database query asynchronously. I don't want to have blocking database queries. Without paging, `ToListAsync()` would be fine. There's no real downsides to calling that, as far as I'm aware. Sure, returning an `IQueryable` works as well, as OData handles the resolving of the queryable. However, I just want to force the reads to be async. – t.baart Mar 11 '16 at 10:00
  • One thing you may have misunderstood from my question: "but then Odata would apply its filters in memory rather than against the query". `ApplyTo` will apply its filters before executing the query, so that's not true. – t.baart Mar 11 '16 at 10:07
  • I said "You could in theory get all of the results into an array asynchronously, but then Odata would apply its filters in memory". It absolutely will if you return a list or array. You need to let the framework take care of executing the query - it is smart enough to stream queries to responses efficiently and execute them asynchronously. – Tim Rogers Mar 11 '16 at 10:38
  • I don't think you understand what `ODataQueryOptions.ApplyTo` does. This will apply all the odata options (filter, orderby, expand, select, etc) to the `IQueryable` I provide it. Only *after* that I would call `ToListAsync` on the `IQueryable` that was returned from `ODataQueryOptions.ApplyTo`. That `IQueryable` therefore already has all the OData options applied to it. Calling ToListAsync after calling `ApplyTo` would be fine, if it would work... Which it doesn't. – t.baart Mar 11 '16 at 10:44
  • Yes I do understand all of that. I am saying there should be no need, and you are going right against WebAPI Odata convention (that it expects you to return an `IQueryable`) and that's why it won't let you return an async result. – Tim Rogers Mar 11 '16 at 10:47
  • OData convention is to return an IQueryable only if you mark the controller action with the `[Queryable]` attribute, which I didn't. Your main point was that my code would retrieve *all* data, and then apply the OData options to this data from memory. This is false. – t.baart Mar 11 '16 at 10:50
  • Yes because normal convention is for OData to apply filters automatically after you have returned the result, not for you to do it yourself. – Tim Rogers Mar 11 '16 at 10:53
  • No, it is not. Both methods are fine. I also explicitely explained that I am in fact using `ODataQueryOptions` (which is part of OData, and supported by OData) which means I don't use the automatic method you are explaining. Anyway, this isn't going anywhere. Thanks for trying to help though. – t.baart Mar 11 '16 at 10:55
  • 1
    Looking back, this is by far the correct answer, you should not be attempting to do this using Async at all. While the DbContext is waiting you cannot use it for anything else so it MUST always block. So OP is was really asking the wrong question in the first place, what they should have done was overridden the `EnableQueryAttribute` to avoid the `TruncatedCollection` issue or used paging from the client as `$top` and `$skip`. – Chris Schaller Oct 03 '21 at 23:13