C# – Await state async

asyncc

For speed we sometimes return response to consumer before state is saved in DB. Sometimes (Mostly for our automated consumers) this can break because the want to make actions on the saved data before it is saved. I wrote this little helper

public async Task<TEntity> GetWithRetry<TEntity>(Expression<Func<TEntity, bool>> predicate, string errorOnTimeout) where TEntity : class
{
    var stopwatch = new Stopwatch();
    stopwatch.Start();

    do
    {
        var entity = await _context.DbSet<TEntity>().FirstOrDefaultAsync(predicate);
        if(entity == null)
            await Task.Delay(100);
        else
            return entity;
    } while(stopwatch.Elapsed < TimeSpan.FromSeconds(1000));

    throw new Exception(errorOnTimeout);
}

Used like

var existingBooking = await GetWithRetry<Booking>(b => b.BookingKey == confirmCommand.BookingKey, "Booking key not found");

Any pitfalls? Task.Delay should scale well since it returns the thread to the pool, and if the data exists in DB the first time around it should not give much more overhead than an extra wrapped task?

This is the current version of the code:

public async Task<TEntity> FirstAsync<TEntity>(Expression<Func<TEntity, bool>> predicate, string errorOnTimeout, TimeSpan? timeout = null) where TEntity : class
{
    var entity = await FirstAsyncOrDefault(predicate, timeout);
    if(entity != null) return entity;

    throw new Exception(errorOnTimeout);
}

public async Task<TEntity> FirstOrDefaultAsync<TEntity>(Expression<Func<TEntity, bool>> predicate, TimeSpan? timeout = null) where TEntity : class
{
    var stopwatch = new Stopwatch();
    stopwatch.Start();

    if(timeout == null)
        timeout = TimeSpan.FromSeconds(1);

    do
    {
        var entity = await DbSet<TEntity>().FirstOrDefaultAsync(predicate);
        if(entity != null) return entity;

        await Task.Delay(100);

    } while(stopwatch.Elapsed < timeout);

    return null;
}

Best Answer

If you just want retries with timeout, you can use a for loop instead of Stopwatch.

const int MaxWaitTime = 1000; //ms
const int SleepTime = 100; //ms

public async Task<TEntity> GetWithRetry<TEntity>(Expression<Func<TEntity, bool>> predicate)
    where TEntity : class
{
    TEntity entity = null;
    // run while under wait time and entity is null
    for (var i = 0; i < MaxWaitTime && entity == null; i += SleepTime) 
    {
        // only delay after first attempt
        if (i != 0) await Task.Delay(SleepTime);

        entity = await _context.DbSet<TEntity>().FirstOrDefaultAsync(predicate);
    }
    // I'd let the caller decide whether to throw on null here
    return entity;
}

But

Seems like a better solution would be to add a call for your customers that only completes when the data is written. Understanding that blocking for IO is a performance problem, don't! Try returning a Task, perhaps with TaskCompletionSource<T>. Then you can run the operation asynchronously without blocking and have the server wake back up to complete the call when the Task completes.

Example batching scenario:

// api method
public async Task DoSomethingAsync(...)
{
    // using bool as dummy type, not really returning result
    var notifySource = new TaskCompletionSource<bool>();

    // pass into your data infrastructure
    // returns without performing save
    MyDatabase.AddToWriteBatch(..., notifySource);

    // wait for the task to complete
    await notifySource.Task;
}

When your data infrastructure gets around to saving the data, it notifies the caller(s) of completion:

public void Process(Batch batch) {
    ...
    // call to database
    Database.BatchWrite(batch.DataCollection);
    foreach (var taskSource in batch.TaskSources)
        taskSource.SetResult(true); // this will trigger Task completion
}

This way, you still are not blocking for IO on the API call. But you can offer the customer a way to be sure their write is committed before performing other operations.

Or

You could use the TaskCompletionSource<TEntity> for reads, and place the reads in the same queue with writes, so that the reads are guaranteed to occur after writes.