Search code examples
c#asp.net-coredependency-injectiondbcontextasp.net-core-hosted-services

Concurrency issue with database context in hosted service


I have a hosted service that processes objects PUT/POST via an API endpoint, that is, as soon as a new entity is given or an existing one is edited, (a) the hosted service starts processing it (a long running process), and (b) the received/modified object returned (as a JSON object) to the API caller.

When PUT/POST an entity, I see run-time errors here and there (e.g., at object JSON serializer) complaining for different issues, such as:

ObjectDisposedException: Cannot access a disposed object. A common cause of this error is disposing a context that was resolved from dependency injection and then later trying to use the same context instance elsewhere in your application. This may occur if you are calling Dispose() on the context, or wrapping the context in a using statement. If you are using dependency injection, you should let the dependency injection container take care of disposing context instances.

or:

InvalidOperationException: A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext.

Initially I was using a database context pool, but according to this, it seems the pooling has known issues with hosted services. Therefore, I switched to regular AddDbContext; however, neither that has solved the problem.

This is how I define the database context and the hosted service:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddCustomDbContext(Configuration);

        // This is the hosted service:
        services.AddHostedService<MyHostedService>();
    }
}

public static class CustomExtensionMethods
{
    public static IServiceCollection AddCustomDbContext(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<MyContext>(
            options =>
            {
                options
                .UseLazyLoadingProxies(true)
                .UseSqlServer(
                    configuration.GetConnectionString("DefaultConnection"),
                    sqlServerOptionsAction: sqlOptions => { sqlOptions.MigrationsAssembly(typeof(Startup).GetTypeInfo().Assembly.GetName().Name); });
            });

        return services;
    }
}

and I access the database context in hosted service as the following (as recommended here):

using(var scope = Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<MyContext>();
}

Edit 1

As mentioned, the errors happen all around the code; however, since I mentioned the errors occurring on the serializer, I am sharing the serializer code in the following:

public class MyJsonConverter : JsonConverter
{
    private readonly Dictionary<string, string> _propertyMappings;

    public MyJsonConverter()
    {
        _propertyMappings = new Dictionary<string, string>
        {
            {"id", nameof(MyType.ID)},
            {"name", nameof(MyType.Name)}
        };
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        JObject obj = new JObject();
        Type type = value.GetType();

        foreach (PropertyInfo prop in type.GetProperties())
        {
            if (prop.CanRead)
            {
                // The above linked errors happen here. 
                object propVal = prop.GetValue(value, null);
                if (propVal != null)
                    obj.Add(prop.Name, JToken.FromObject(propVal, serializer));
            }
        }

        obj.WriteTo(writer);
    }
}

Update 2

An example API endpoint is as the following:

[Route("api/v1/[controller]")]
[ApiController]
public class MyTypeController : ControllerBase
{
    private readonly MyContext _context;
    private MyHostedService _service;

    public MyTypeController (
        MyContext context,
        MyHostedService service)
    {
        _context = context;
        _service = service
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<IEnumerable<MyType>>> GetMyType(int id)
    {
        return await _context.MyTypes.FindAsync(id);
    }

    [HttpPost]
    public async Task<ActionResult<MyType>> PostMyType(MyType myType)
    {
        myType.Status = State.Queued;
        _context.MyTypes.Add(myType);
        _context.MyTypes.SaveChangesAsync().ConfigureAwait(false);

        // the object is queued in the hosted service for execution.
        _service.Enqueue(myType);

        return CreatedAtAction("GetMyType", new { id = myType.ID }, myType);
    }
}

Solution

  • The following lines are most likely causing the ObjectDisposedException error:

    return await _context.MyTypes.FindAsync(id);
    

    and

    return CreatedAtAction("GetMyType", new { id = myType.ID }, myType);
    

    This is because you are relying on this variable:

    private readonly MyContext _context;
    

    Since object myType has been attached to that context.

    As I mentioned before, it is not a good idea to send context entities for serialization because by the time the serializer has a chance to fire, the context might have been disposed. Use a model (meaning a class in the Models folder) instead and map all the relevant properties from your real entity to it. for instance, you could create a class called MyTypeViewModel that contains only the properties that you need to return:

    public class MyTypeViewModel
    {
        public MyTypeViewModel(MyType obj)
        {
            Map(obj);
        }
    
        public int ID { get; set; }
    
        private void Map(MyType obj)
        {
            this.ID = obj.ID;
        }
    }
    

    Then instead of returning the entity, use the view model:

    var model = new MyTypeViewModel(myType);
    return CreatedAtAction("GetMyType", new { id = myType.ID }, model);
    

    As far as the InvalidOperationException, my educated guess is that since you are not awaiting the SaveChangesAsync method, the serializer is firing while the original operation is still in progress, causing a double hit to the context, resulting in the error.

    Using await on the SaveChangesAsync method should fix that, but you still need to stop sending lazy-loaded entities for serialization.

    Upon further review, the service itself might also be causing issues since you are passing it a reference to object myType:

    _service.Enqueue(myType);
    

    The same two issues may occur if the service is doing something with the object that causes a call to a now-disposed context or at the same time as other asynchronous parts (e.g. serialization) attempt to lazy-load stuff.