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 all 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
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
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
@@ -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
Expand Up @@ -28,5 +28,10 @@ public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}

public void Dispose()
{
GC.SuppressFinalize(this);
}
}
}
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
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
84 changes: 59 additions & 25 deletions src/BaGet.Core/PackageDatabase.cs
Expand Up @@ -10,41 +10,45 @@ namespace BaGet.Core
{
public class PackageDatabase : IPackageDatabase
{
private readonly IContext _context;
private readonly Func<IContext> _newContext;

public PackageDatabase(IContext context)
public PackageDatabase(Func<IContext> newContext)
{
_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);

await _context.SaveChangesAsync(cancellationToken);

context.Packages.Add(package);
await context.SaveChangesAsync(cancellationToken);
return PackageAddResult.Success;
}
catch (DbUpdateException e)
when (_context.IsUniqueConstraintViolationException(e))
when (context.IsUniqueConstraintViolationException(e))
{
return PackageAddResult.PackageAlreadyExists;
}
}

public async Task<bool> ExistsAsync(string id, CancellationToken cancellationToken)
{
return await _context
using var context = _newContext();

return await context
.Packages
.Where(p => p.Id == id)
.AnyAsync(cancellationToken);
}

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

return await context
.Packages
.Where(p => p.Id == id)
.Where(p => p.NormalizedVersionString == version.ToNormalizedString())
Expand All @@ -53,7 +57,9 @@ public async Task<bool> ExistsAsync(string id, NuGetVersion version, Cancellatio

public async Task<IReadOnlyList<Package>> FindAsync(string id, bool includeUnlisted, CancellationToken cancellationToken)
{
var query = _context.Packages
using var context = _newContext();

var query = context.Packages
.Include(p => p.Dependencies)
.Include(p => p.PackageTypes)
.Include(p => p.TargetFrameworks)
Expand All @@ -73,7 +79,9 @@ public async Task<IReadOnlyList<Package>> FindAsync(string id, bool includeUnlis
bool includeUnlisted,
CancellationToken cancellationToken)
{
var query = _context.Packages
using var context = _newContext();

var query = context.Packages
.Include(p => p.Dependencies)
.Include(p => p.TargetFrameworks)
.Where(p => p.Id == id)
Expand All @@ -99,26 +107,27 @@ 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)
.Include(p => p.TargetFrameworks)
.FirstOrDefaultAsync(cancellationToken);

if (package == null)
if (package is null)
{
return false;
}

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

context.Packages.Remove(package);
await context.SaveChangesAsync(cancellationToken);
return true;
}

Expand All @@ -128,20 +137,45 @@ 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)
if (package is null)
{
action(package);
await _context.SaveChangesAsync(cancellationToken);

return true;
return false;
}

return false;
action(package);
await context.SaveChangesAsync(cancellationToken);
return true;
}

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
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
@@ -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
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