loic-sharma / BaGet

A lightweight NuGet and symbol server
https://loic-sharma.github.io/BaGet/
MIT License
2.58k stars 644 forks source link

Addressing concurrency exceptions when incrementing the download count. #716

Open inkysquid opened 2 years ago

inkysquid commented 2 years ago

Summary

Automatic retry when incrementing the download count throws DbUpdateConcurrencyException.

Problem

When a package is requested, BaGet will update the package record, incrementing the Downloads field by 1. This is done with EF Core, where the record is first retrieved from the database, modified in memory, and then saved with a call to SaveChangesAsync().

If the record in the database is modified in between retrieving the record and the call to SaveChangesAsync, then a DbUpdateConcurrencyException is raised, leading to a 500 status code and the following error message:

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

This can happen when there are two requests for the same package around the same time, which should be expected for parallel CI pipelines running dotnet restore or for popular packages.

Solution

I have fixed the issue for myself (I think) and have created this PR in case you would like to merge it, or to help anyone else with the issue.

The solution works by retrying the operation up to 5 attempts, and then throwing the error.

Something I needed to do (which you might not be happy with) is to change the DbContext registration from scoped to transient. This is so that I can create a new DbContext for each attempt rather than fix up a DbContext in an invalid state.

I also needed to fix up the tests, which were failing in my local environment (because of UTC+8).

Another idea might be to find a platform independent way to run the following SQL, without first having to retrieve the record and therefore risk the concurrency error in the first place.

UPDATE
    Packages
SET
    Downloads = Downloads + 1 
WHERE 
    Id = @id AND
    NormalizedVersionString = @normalizedVersionString