Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addressing concurrency exceptions when incrementing the download count. #716

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ publish/
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
ServiceDependencies/

# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
Expand Down
2 changes: 1 addition & 1 deletion src/BaGet.Core/BaGet.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>

<LangVersion>8</LangVersion>
<PackageTags>NuGet</PackageTags>
<Description>The core libraries that power BaGet.</Description>
</PropertyGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/BaGet.Core/Entities/IContext.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace BaGet.Core
{
public interface IContext
public interface IContext : IDisposable
{
DatabaseFacade Database { get; }

Expand Down
5 changes: 5 additions & 0 deletions src/BaGet.Core/Entities/NullContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,10 @@ public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}

public void Dispose()
{
GC.SuppressFinalize(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ public static bool HasStorageType(this IConfiguration config, string value)
Action<IServiceProvider, DbContextOptionsBuilder> configureContext)
where TContext : DbContext, IContext
{
services.TryAddScoped<IContext>(provider => provider.GetRequiredService<TContext>());
services.TryAddTransient<IContext>(provider => provider.GetRequiredService<TContext>());
services.TryAddTransient<IPackageDatabase>(provider => provider.GetRequiredService<PackageDatabase>());

services.AddDbContext<TContext>(configureContext);
services.AddDbContext<TContext>(configureContext, contextLifetime: ServiceLifetime.Transient);

services.AddProvider<IContext>((provider, config) =>
{
Expand Down
2 changes: 1 addition & 1 deletion src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ private static void AddDefaultProviders(this IServiceCollection services)

private static void AddFallbackServices(this IServiceCollection services)
{
services.TryAddScoped<IContext, NullContext>();
services.TryAddTransient<IContext, NullContext>();

// BaGet's services have multiple implementations that live side-by-side.
// The application will choose the implementation using one of two ways:
Expand Down
54 changes: 44 additions & 10 deletions src/BaGet.Core/PackageDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,28 @@ namespace BaGet.Core
public class PackageDatabase : IPackageDatabase
{
private readonly IContext _context;
private readonly Func<IContext> _newContext;

public PackageDatabase(IContext context)
public PackageDatabase(IContext context, Func<IContext> newContext)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest naming this context generator so it's clearer what it is?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to name the factory closer to the idiomatic C# code, i.e.

using var context =_newContext();

as an analog to

using var context = new Context();

I can can probably be convinced to rename to _createContext ;)

Let's be honest though, we all nab the fixes and then apply our own naming conventions here.

{
_context = context ?? throw new ArgumentNullException(nameof(context));
_newContext = newContext ?? throw new ArgumentNullException(nameof(newContext));
}

public async Task<PackageAddResult> AddAsync(Package package, CancellationToken cancellationToken)
{
using var context = _newContext();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read as confusing with the field _context and this.

Why is the field still required? Could it not always generate context?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree and have updated this. Would like to change this everywhere to be honest. I don't like relying on DI to manage DbContext lifetime.


try
{
_context.Packages.Add(package);
context.Packages.Add(package);

await _context.SaveChangesAsync(cancellationToken);
await context.SaveChangesAsync(cancellationToken);

return PackageAddResult.Success;
}
catch (DbUpdateException e)
when (_context.IsUniqueConstraintViolationException(e))
when (context.IsUniqueConstraintViolationException(e))
{
return PackageAddResult.PackageAlreadyExists;
}
Expand Down Expand Up @@ -99,12 +103,14 @@ public Task<bool> RelistPackageAsync(string id, NuGetVersion version, Cancellati

public async Task AddDownloadAsync(string id, NuGetVersion version, CancellationToken cancellationToken)
{
await TryUpdatePackageAsync(id, version, p => p.Downloads += 1, cancellationToken);
await TryUpdatePackageWithConcurrencyRetryAsync(id, version, p => p.Downloads += 1, cancellationToken);
}

public async Task<bool> HardDeletePackageAsync(string id, NuGetVersion version, CancellationToken cancellationToken)
{
var package = await _context.Packages
using var context = _newContext();

var package = await context.Packages
.Where(p => p.Id == id)
.Where(p => p.NormalizedVersionString == version.ToNormalizedString())
.Include(p => p.Dependencies)
Expand All @@ -116,8 +122,8 @@ public async Task<bool> HardDeletePackageAsync(string id, NuGetVersion version,
return false;
}

_context.Packages.Remove(package);
await _context.SaveChangesAsync(cancellationToken);
context.Packages.Remove(package);
await context.SaveChangesAsync(cancellationToken);

return true;
}
Expand All @@ -128,20 +134,48 @@ public async Task<bool> HardDeletePackageAsync(string id, NuGetVersion version,
Action<Package> action,
CancellationToken cancellationToken)
{
var package = await _context.Packages
using var context = _newContext();

var package = await context.Packages
.Where(p => p.Id == id)
.Where(p => p.NormalizedVersionString == version.ToNormalizedString())
.FirstOrDefaultAsync();

if (package != null)
{
action(package);
await _context.SaveChangesAsync(cancellationToken);
await context.SaveChangesAsync(cancellationToken);

return true;
}

return false;
}

private async Task<bool> TryUpdatePackageWithConcurrencyRetryAsync(
string id,
NuGetVersion version,
Action<Package> action,
CancellationToken cancellationToken)
{
var attempts = 0;

while (true)
{
try
{
return await TryUpdatePackageAsync(id, version, action, cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
attempts++;

if (attempts >= 5)
{
throw;
}
}
}
}
}
}
3 changes: 2 additions & 1 deletion src/BaGet/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ public void ConfigureServices(IServiceCollection services)
// You can swap between implementations of subsystems like storage and search using BaGet's configuration.
// Each subsystem's implementation has a provider that reads the configuration to determine if it should be
// activated. BaGet will run through all its providers until it finds one that is active.
services.AddScoped(DependencyInjectionExtensions.GetServiceFromProviders<IContext>);
services.AddTransient(DependencyInjectionExtensions.GetServiceFromProviders<IContext>);
services.AddSingleton<Func<IContext>>(s => () => s.GetService<IContext>());
services.AddTransient(DependencyInjectionExtensions.GetServiceFromProviders<IStorageService>);
services.AddTransient(DependencyInjectionExtensions.GetServiceFromProviders<IPackageDatabase>);
services.AddTransient(DependencyInjectionExtensions.GetServiceFromProviders<ISearchService>);
Expand Down
2 changes: 2 additions & 0 deletions tests/BaGet.Core.Tests/Metadata/RegistrationBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Moq;
Expand Down Expand Up @@ -60,6 +61,7 @@ private Package GetTestPackage(string packageId, string version)
PackageTypes = new List<PackageType> { new PackageType { Name = "test" } },
Dependencies = new List<PackageDependency> { },
Version = new NuGetVersion(version),
Published = DateTimeOffset.MinValue.UtcDateTime
};
}
}
Expand Down
2 changes: 1 addition & 1 deletion tests/BaGet.Core.Tests/Services/PackageDatabaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ public class FactsBase
public FactsBase()
{
_context = new Mock<IContext>();
_target = new PackageDatabase(_context.Object);
_target = new PackageDatabase(_context.Object, () => new Mock<IContext>().Object);
}
}
}
Expand Down