Skip to content

Commit 6ebb7a2

Browse files
authored
Fix concurrency/consistency issues in Gallery API push (#6600)
1 parent dc86fe8 commit 6ebb7a2

8 files changed

Lines changed: 140 additions & 6 deletions

File tree

src/NuGetGallery.Core/NuGetGallery.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@
161161
<Compile Include="Services\CoreSymbolPackageService.cs" />
162162
<Compile Include="Services\CorePackageService.cs" />
163163
<Compile Include="Services\CryptographyService.cs" />
164+
<Compile Include="Services\PackageAlreadyExistsException.cs" />
164165
<Compile Include="Services\FileAlreadyExistsException.cs" />
165166
<Compile Include="Services\FileUriPermissions.cs" />
166167
<Compile Include="Services\IAccessCondition.cs" />
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace NuGetGallery
7+
{
8+
[Serializable]
9+
public sealed class PackageAlreadyExistsException : Exception
10+
{
11+
public PackageAlreadyExistsException() { }
12+
public PackageAlreadyExistsException(string message) : base(message) { }
13+
public PackageAlreadyExistsException(string message, Exception inner) : base(message, inner) { }
14+
}
15+
}

src/NuGetGallery/Controllers/ApiController.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@
2525
using NuGetGallery.Configuration;
2626
using NuGetGallery.Filters;
2727
using NuGetGallery.Infrastructure.Authentication;
28-
using NuGetGallery.Infrastructure.Mail;
29-
using NuGetGallery.Infrastructure.Mail.Messages;
3028
using NuGetGallery.Packaging;
3129
using NuGetGallery.Security;
3230
using PackageIdValidator = NuGetGallery.Packaging.PackageIdValidator;
@@ -731,6 +729,10 @@ await AuditingService.SaveAuditRecordAsync(
731729
{
732730
return BadRequestForExceptionMessage(ex);
733731
}
732+
catch (PackageAlreadyExistsException ex)
733+
{
734+
return new HttpStatusCodeWithBodyResult(HttpStatusCode.Conflict, ex.Message);
735+
}
734736
catch (EntityException ex)
735737
{
736738
return BadRequestForExceptionMessage(ex);

src/NuGetGallery/Services/PackageService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,8 @@ private Package CreatePackageFromNuGetPackage(PackageRegistration packageRegistr
479479

480480
if (package != null)
481481
{
482-
throw new EntityException(
483-
"A package with identifier '{0}' and version '{1}' already exists.", packageRegistration.Id, package.Version);
482+
throw new PackageAlreadyExistsException(
483+
string.Format(Strings.PackageExistsAndCannotBeModified, packageRegistration.Id, package.Version));
484484
}
485485

486486
package = new Package();

src/NuGetGallery/Services/PackageUploadService.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Data.Entity.Infrastructure;
7+
using System.Data.SqlClient;
68
using System.IO;
79
using System.Linq;
810
using System.Threading;
@@ -427,7 +429,7 @@ await _packageFileService.DeleteValidationPackageFileAsync(
427429
// commit all changes to database as an atomic transaction
428430
await _entitiesContext.SaveChangesAsync();
429431
}
430-
catch
432+
catch (Exception ex)
431433
{
432434
// If saving to the DB fails for any reason we need to delete the package we just saved.
433435
if (package.PackageStatusKey == PackageStatus.Validating)
@@ -443,10 +445,36 @@ await _packageFileService.DeletePackageFileAsync(
443445
package.Version);
444446
}
445447

446-
throw;
448+
return ReturnConflictOrThrow(ex);
447449
}
448450

449451
return PackageCommitResult.Success;
450452
}
453+
454+
private PackageCommitResult ReturnConflictOrThrow(Exception ex)
455+
{
456+
if (ex is DbUpdateConcurrencyException concurrencyEx)
457+
{
458+
return PackageCommitResult.Conflict;
459+
}
460+
else if (ex is DbUpdateException dbUpdateEx)
461+
{
462+
if (dbUpdateEx.InnerException?.InnerException != null)
463+
{
464+
if (dbUpdateEx.InnerException.InnerException is SqlException sqlException)
465+
{
466+
switch (sqlException.Number)
467+
{
468+
case 547: // Constraint check violation
469+
case 2601: // Duplicated key row error
470+
case 2627: // Unique constraint error
471+
return PackageCommitResult.Conflict;
472+
}
473+
}
474+
}
475+
}
476+
477+
throw ex;
478+
}
451479
}
452480
}

tests/NuGetGallery.Facts/Controllers/ApiControllerFacts.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,52 @@ public async Task WillReturnConflictIfCommittingPackageReturnsConflict()
843843
controller.MockEntitiesContext.VerifyCommitted(Times.Never());
844844
}
845845

846+
[Fact]
847+
public async Task WillReturnConflictIfGeneratePackageThrowsPackageAlreadyExistsException()
848+
{
849+
// Arrange
850+
var packageId = "theId";
851+
var nuGetPackage = TestPackage.CreateTestPackageStream(packageId, "1.0.42");
852+
853+
var currentUser = new User("currentUser") { Key = 1, EmailAddress = "[email protected]" };
854+
var controller = new TestableApiController(GetConfigurationService());
855+
controller.SetCurrentUser(currentUser);
856+
controller.SetupPackageFromInputStream(nuGetPackage);
857+
858+
var owner = new User("owner") { Key = 2, EmailAddress = "[email protected]" };
859+
860+
Expression<Func<IApiScopeEvaluator, ApiScopeEvaluationResult>> evaluateApiScope =
861+
x => x.Evaluate(
862+
currentUser,
863+
It.IsAny<IEnumerable<Scope>>(),
864+
ActionsRequiringPermissions.UploadNewPackageId,
865+
It.Is<ActionOnNewPackageContext>((context) => context.PackageId == packageId),
866+
NuGetScopes.PackagePush);
867+
868+
controller.MockApiScopeEvaluator
869+
.Setup(evaluateApiScope)
870+
.Returns(new ApiScopeEvaluationResult(owner, PermissionsCheckResult.Allowed, scopesAreValid: true));
871+
controller
872+
.MockPackageUploadService
873+
.Setup(x => x.GeneratePackageAsync(It.IsAny<string>(),
874+
It.IsAny<PackageArchiveReader>(),
875+
It.IsAny<PackageStreamMetadata>(),
876+
It.IsAny<User>(),
877+
It.IsAny<User>()))
878+
.Throws(new PackageAlreadyExistsException("Package exists"));
879+
880+
// Act
881+
var result = await controller.CreatePackagePut();
882+
883+
// Assert
884+
ResultAssert.IsStatusCode(result, HttpStatusCode.Conflict);
885+
controller.MockPackageUploadService.Verify(x => x.GeneratePackageAsync(It.IsAny<string>(),
886+
It.IsAny<PackageArchiveReader>(),
887+
It.IsAny<PackageStreamMetadata>(),
888+
It.IsAny<User>(),
889+
It.IsAny<User>()), Times.Once);
890+
}
891+
846892
[Fact]
847893
public async Task WillReturnValidationWarnings()
848894
{

tests/NuGetGallery.Facts/Services/PackageServiceFacts.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,26 @@ private async Task WillSaveTheCreatedPackageWhenThePackageRegistrationAlreadyExi
423423
Assert.Same(packageRegistration.Packages.ElementAt(0), package);
424424
}
425425

426+
[Fact]
427+
private async Task WillThrowWhenThePackageRegistrationAndVersionAlreadyExists()
428+
{
429+
var currentUser = new User();
430+
var packageId = "theId";
431+
var packageVersion = "1.0.32";
432+
var nugetPackage = PackageServiceUtility.CreateNuGetPackage(packageId, packageVersion);
433+
var packageRegistration = new PackageRegistration
434+
{
435+
Id = packageId,
436+
Owners = new HashSet<User> { currentUser },
437+
};
438+
packageRegistration.Packages.Add(new Package() { Version = packageVersion });
439+
var packageRegistrationRepository = new Mock<IEntityRepository<PackageRegistration>>();
440+
var service = CreateService(packageRegistrationRepository: packageRegistrationRepository, setup:
441+
mockPackageService => { mockPackageService.Setup(x => x.FindPackageRegistrationById(It.IsAny<string>())).Returns(packageRegistration); });
442+
443+
await Assert.ThrowsAsync<PackageAlreadyExistsException>(async () => await service.CreatePackageAsync(nugetPackage.Object, new PackageStreamMetadata(), currentUser, currentUser, isVerified: false));
444+
}
445+
426446
[Fact]
427447
private async Task WillThrowIfTheNuGetPackageIdIsLongerThanMaxPackageIdLength()
428448
{

tests/NuGetGallery.Facts/Services/PackageUploadServiceFacts.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Data.Entity.Infrastructure;
67
using System.IO;
78
using System.Linq;
89
using System.Threading;
@@ -1037,6 +1038,27 @@ public async Task DeletesPackageIfDatabaseCommitFailsWhenAvailable()
10371038
Assert.Same(_unexpectedException, exception);
10381039
}
10391040

1041+
[Fact]
1042+
public async Task ReturnsConflictWhenDBCommitThrowsConcurrencyViolations()
1043+
{
1044+
_package.PackageStatusKey = PackageStatus.Available;
1045+
var ex = new DbUpdateConcurrencyException("whoops!");
1046+
_entitiesContext
1047+
.Setup(x => x.SaveChangesAsync())
1048+
.Throws(ex);
1049+
1050+
var result = await _target.CommitPackageAsync(_package, _packageFile);
1051+
1052+
_packageFileService.Verify(
1053+
x => x.DeletePackageFileAsync(Id, Version),
1054+
Times.Once);
1055+
_packageFileService.Verify(
1056+
x => x.DeletePackageFileAsync(It.IsAny<string>(), It.IsAny<string>()),
1057+
Times.Once);
1058+
1059+
Assert.Equal(PackageCommitResult.Conflict, result);
1060+
}
1061+
10401062
[Fact]
10411063
public async Task DeletesPackageIfDatabaseCommitFailsWhenValidating()
10421064
{

0 commit comments

Comments
 (0)