0

I have a Blazor component that has to display data from a long running operation. For this reason I display a spinner, but since it takes long time I want to be able to cancel this loading when for example the user navigates away (e.g. the user clicks login while data is loading).

I implemented the Dispose pattern with the CancellationTokenSource object in my component, I made my function async with a Token as parameter but it seems that the token's "IsCanceled" is never set to true inside my load data function, neither it throws OperationCanceledException. If I make a test with a dummy function that simply awaits with Task.Delay for 20 sec and I pass the token it is correctly canceled. What am I doing wrong?

The end result is that while data is loading and the spinner is showing, if the user clicks on a button in order to navigate away it waits for the data loading completion.

The view where I display my data; "LoadingBox" shows a spinner while the list is not created.

<Card>
    <CardHeader><h3>Ultime offerte</h3></CardHeader>
    <CardBody>
        <div class="overflow-auto" style="max-height: 550px;">
            <div class="@(offersAreLoading ?"text-danger":"text-info")">LOADING: @offersAreLoading</div>
            <LoadingBox IsLoading="lastOffers == null">
                @if (lastOffers != null)
                {
                    @if (lastOffers.Count == 0)
                    {
                        <em>Non sono presenti offerte.</em>
                    }
                    <div class="list-group list-group-flush">
                        @foreach (var off in lastOffers)
                        {
                            <div class="list-group-item list-group-item-action flex-column align-items-start">
                                <div class="d-flex w-100 justify-content-between">
                                    <h5 class="mb-1">
                                        <a href="@(NavigationManager.BaseUri)offerta/@off.Oarti">
                                            @off.CodiceOfferta4Humans-@off.Versione
                                        </a>
                                    </h5>
                                    <small>@((int) ((DateTime.Now - off.Created).TotalDays)) giorni fa</small>
                                </div>
                                <p class="mb-1"><em>@(off.OggettoOfferta.Length > 50 ? off.OggettoOfferta.Substring(0, 50) + "..." : off.OggettoOfferta)</em></p>
                                <small>@off?.Redattore?.Username - @off.Created</small>
                            </div>
                        }
                    </div>
                }

            </LoadingBox>
        </div>
    </CardBody>
</Card>

The component code-behind. Here I call the long-running function (GetRecentAsync) that I want to cancel when the user navigates away or if the user does some other operation:

public partial class Test : IDisposable
    {
        private CancellationTokenSource cts = new();
        private IList<CommercialOffer> lastOffers;
        private bool offersAreLoading;
        [Inject] public CommercialOfferService CommercialOfferService { get; set; }
        async Task LoadLastOffers()
        {
            offersAreLoading = true;
            await InvokeAsync(StateHasChanged);
            var lo = await CommercialOfferService.GetRecentAsync(cancellationToken: cts.Token);
            lastOffers = lo;
            offersAreLoading = false;
            await InvokeAsync(StateHasChanged);


        }

        async Task fakeLoad()
        {
            offersAreLoading = true;
            await InvokeAsync(StateHasChanged);
            await Task.Delay(TimeSpan.FromSeconds(20), cts.Token);
            offersAreLoading = false;
            await InvokeAsync(StateHasChanged);
        }

        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                await LoadLastOffers();
            }
            await base.OnAfterRenderAsync(firstRender);
        }

        public void Dispose()
        {
            cts.Cancel();
            cts.Dispose();
        }
    }
public async Task<List<CommercialOffer>> GetRecentAsync(CancellationToken cancellationToken)
        {
            try
            {
                cancellationToken.ThrowIfCancellationRequested();
                var result = await _cache.GetOrCreateAsync<List<CommercialOffer>>("recentOffers", async entry =>
                {
                    entry.AbsoluteExpiration = DateTimeOffset.Now.Add(new TimeSpan(0, 0, 0, 30));
                    var list = await _unitOfWork.CommercialOfferRepository.GetAllWithOptionsAsync();
                    foreach (var commercialOffer in list)
                    {
                        // sta operazione è pesante, per questo ho dovuto cachare

                        // BOTH ISCANCELLATIONREQUESTED AND THROWIFCANCELLATINREQUESTED DOES NOT WORK, ISCANCELLATIONREQUESTED IS ALWAYS FALSE.

                        cancellationToken.ThrowIfCancellationRequested();
                        if (cancellationToken.IsCancellationRequested) return new List<CommercialOffer>();
                       
                        await _populateOfferUsersAsync(commercialOffer);
                    }
                    return list.Take(15).OrderByDescending(o => o.Oarti).ToList();
                });

                return result;
            }
            catch (OperationCanceledException)
            {
                // HERE I SET A BREAKPOINT IN ORDER TO SEE IF IT RUNS, BUT IT DOESN'T WORK
            }
        }

Thanks!

EDIT 20/07/2021

Thanks @Henk Holterman. GetRecentAsync gets all recent commercial offers, compiled by a simple form and having some data as a usual use case. Each of these commercial offers refer to 4 users (who manages the offer, the superior, the approver, etc.) and I populate with a foreach loop each of these users, for each commercial offer I want to display.

I know I should create from the start the entire entity (commercial offer) from the SQL query, but I need this for a matter of order and separation of concerns.

So, _populateOfferUsersAsync(commercialOffer) queries for 4 users of an offer, creates these 4 entities and assign them to the offer:

private async Task _populateOfferUsersAsync(CommercialOffer commercialOffer)
        {
            commercialOffer.Responsabile = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdResponsabile);
            commercialOffer.Redattore = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdRedattore);
            commercialOffer.Approvatore = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdApprovatore);
            commercialOffer.Revisore = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdRevisore);
        }

Under the hood I use Dapper for DB queries:

public async Task<User> GetByIdAsync(long id)
        {
            var queryBuilder = _dbTransaction.Connection.QueryBuilder($@"SELECT * FROM GEUTENTI /**where**/");
            queryBuilder.Where($"CUSER = {id}");
            queryBuilder.Where($"BSTOR = 'A'");
            queryBuilder.Where($"BDELE = 'S'");

            var users = await queryBuilder.QueryAsync<User>(_dbTransaction);
            return users.FirstOrDefault();
        }

From what I've seen there's no simple and effective way to pass a CancellationToken to stop Dapper queries, it might be me or Dapper being poor for that stuff

H H
  • 263,252
  • 30
  • 330
  • 514
exrezzo
  • 497
  • 1
  • 5
  • 24
  • I'm not very familiar with Blazor, but I notice in [this article](https://www.meziantou.net/canceling-background-tasks-when-a-user-navigates-away-from-a-blazor-component.htm) the `@implements IDisposable` and the Dispose method are added directly to the component code, not to the model class. Is that something to look into? – StriplingWarrior Jul 16 '21 at 15:36
  • OK, I missed the Dapper angle. That is your most important tag here. – H H Jul 20 '21 at 09:30
  • Ans see this https://stackoverflow.com/q/25540793/60761 – H H Jul 20 '21 at 09:33
  • Let's consider that Dapper would work with the token (actually not very good) but the fact that I can't stop the loop inside **GetRecentAsync** is something that doesn't make sense. In that case I'm reading data from db, so it's not so necessary to work on cancelling the sql transaction (it would be a different story if I was writing to db, in that case I imagine that I would have to rollback the transaction). The best I achieved by now is by using `await Task.Run(() => CommercialOfferService.GetRecentAsync(cancellationToken: cts.Token), cts.Token);` now it throws – exrezzo Jul 20 '21 at 10:52

1 Answers1

2

What am I doing wrong?

It is important to forward your cancellation token to all async I/O methods:

// var list = await _unitOfWork.CommercialOfferRepository.GetAllWithOptionsAsync();
var list = await _unitOfWork.CommercialOfferRepository.GetAllWithOptionsAsync(cancellationToken);

And then of course modify GetAllWithOptionsAsync() accordingly. All async methods in entity framework have an overload that accepts a CancellationToken.

... to navigate away it waits for the data loading completion.

That figures when GetAllWithOptionsAsync() takes up most of the time. The next foreach loop should break on a cancellation but that might not be noticeable.
Still, _populateOfferUsersAsync(commercialOffer) should also take a CancellationToken as a parameter.

As you can see from your own FakeLoad(), Blazor and CancellationTokenSource aren't broken.

H H
  • 263,252
  • 30
  • 330
  • 514
  • Unfortunately even if i pass the token to subsequent functions, such as **_populateOfferUsersAsync(commercialOffer)** I can't get the OperationCanceledException, neither isCancellationRequested == true anywhere, it seems like the token inside these functions (from GetAllWithOptionsAsync on) it's never set to canceled, like it is another object or i don't know what – exrezzo Jul 19 '21 at 08:52
  • 1
    Can you post an outline for GetAllWithOptionsAsync and _populateOfferUsersAsync ? How async are they? – H H Jul 19 '21 at 13:13
  • I edited my question with more information – exrezzo Jul 20 '21 at 08:20