Skip to content

Commit

Permalink
Explores support for concurrency tokens using PostgreSQL
Browse files Browse the repository at this point in the history
*Update (2021-11-08): Rebased on latest changes in master branch*

Because we fetch the row before update and apply changes on that, a concurrency violation is only reported when two concurrent requests update the same row in parallel. Instead, we want to produce an error if the token sent by the user does not match the stored token. To do that, we need to convince EF Core to use that as original version. That's not too hard.

Now the problem is that there is no way to send the token for relationships or deleting a resource. Skipped tests have been added to demonstrate this.

We could fetch such related rows upfront to work around that, but that kinda defeats the purpose of using concurrency tokens in the first place. It may be more correct to fail when a user is trying to add a related resource that has changed since it was fetched. This reasoning may be a bit too puristic and impractical, but at least that's how EF Core seems to handle it.

Solutions considerations:
- Add 'version' to resource identifier object, so the client can send it. The spec does not explicitly forbid adding custom fields, however 'meta' would probably be the recommended approach. Instead of extending the definition, we could encode it in the StringId.
- Once we have access to that token value, we need to somehow map that to 'the' resource property. What if there are multiple concurrency token properties on a resource? And depending on the database used, this could be typed as numeric, guid, timestamp, binary or something else.
- Given that PostgreSQL uses a number (uint xmin), should we obfuscate or even encrypt that? If the latter, we need to add an option for api developers to set the encryption key.

See also:
json-api/json-api#600
json-api/json-api#824
  • Loading branch information
Bart Koelman committed Nov 9, 2021
1 parent b88d39e commit 19110ee
Show file tree
Hide file tree
Showing 10 changed files with 856 additions and 7 deletions.
1 change: 1 addition & 0 deletions JsonApiDotNetCore.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,6 @@ $left$ = $right$;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=unarchive/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Workflows/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmin/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xunit/@EntryIndexedValue">True</s:Boolean>
</wpf:ResourceDictionary>
23 changes: 23 additions & 0 deletions src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.Serialization.Objects;

namespace JsonApiDotNetCore.Errors
{
/// <summary>
/// The error that is thrown when data has been modified on the server since the resource was retrieved.
/// </summary>
[PublicAPI]
public sealed class DataConcurrencyException : JsonApiException
{
public DataConcurrencyException(Exception exception)
: base(new ErrorObject(HttpStatusCode.Conflict)
{
Title = "The concurrency token is missing or does not match the server version. " +
"This indicates that data has been modified since the resource was retrieved."
}, exception)
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
Expand Down Expand Up @@ -207,7 +208,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r
DbSet<TResource> dbSet = _dbContext.Set<TResource>();
await dbSet.AddAsync(resourceForDatabase, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken);

Expand Down Expand Up @@ -283,15 +284,43 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r
attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest));
}

bool hasConcurrencyToken = RestoreConcurrencyToken(resourceFromRequest, resourceFromDatabase);

await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, hasConcurrencyToken);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken);

_dbContext.ResetChangeTracker();
}

private bool RestoreConcurrencyToken(TResource resourceFromRequest, TResource resourceFromDatabase)
{
bool hasConcurrencyToken = false;

foreach (var propertyEntry in _dbContext.Entry(resourceFromDatabase).Properties)
{
if (propertyEntry.Metadata.IsConcurrencyToken)
{
// Overwrite the ConcurrencyToken coming from database with the one from the request body.
// If they are different, EF Core throws a DbUpdateConcurrencyException on save.

PropertyInfo? concurrencyTokenProperty = typeof(TResource).GetProperty(propertyEntry.Metadata.PropertyInfo.Name);

if (concurrencyTokenProperty != null)
{
object? concurrencyTokenFromRequest = concurrencyTokenProperty.GetValue(resourceFromRequest);
propertyEntry.OriginalValue = concurrencyTokenFromRequest;

hasConcurrencyToken = true;
}
}
}

return hasConcurrencyToken;
}

protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue)
{
if (relationship is HasOneAttribute)
Expand Down Expand Up @@ -341,7 +370,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke

_dbContext.Remove(resourceTracked);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken);
}
Expand Down Expand Up @@ -412,7 +441,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r

await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken);
}
Expand Down Expand Up @@ -445,7 +474,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet<IIdentif

await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);
}
Expand Down Expand Up @@ -506,7 +535,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour

await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);

await SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken, false);

await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
}
Expand Down Expand Up @@ -556,7 +585,7 @@ private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship
return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true };
}

protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken)
protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken, bool hasConcurrencyToken)
{
cancellationToken.ThrowIfCancellationRequested();

Expand All @@ -568,6 +597,11 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
}
catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException)
{
if (hasConcurrencyToken && exception is DbUpdateConcurrencyException)
{
throw new DataConcurrencyException(exception);
}

if (_dbContext.Database.CurrentTransaction != null)
{
// The ResourceService calling us needs to run additional SQL queries after an aborted transaction,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;

// @formatter:wrap_chained_method_calls chop_always

namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens
{
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ConcurrencyDbContext : DbContext
{
public DbSet<Disk> Disks => Set<Disk>();
public DbSet<Partition> Partitions => Set<Partition>();

public ConcurrencyDbContext(DbContextOptions<ConcurrencyDbContext> options)
: base(options)
{
}

protected override void OnModelCreating(ModelBuilder builder)
{
// https://www.npgsql.org/efcore/modeling/concurrency.html

builder.Entity<Disk>()
.UseXminAsConcurrencyToken();

builder.Entity<Partition>()
.UseXminAsConcurrencyToken();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using Bogus;
using JsonApiDotNetCore;
using TestBuildingBlocks;

// @formatter:wrap_chained_method_calls chop_always
// @formatter:keep_existing_linebreaks true

namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens
{
internal sealed class ConcurrencyFakers : FakerContainer
{
private const ulong OneGigabyte = 1024 * 1024 * 1024;
private static readonly string[] KnownFileSystems = ArrayFactory.Create("NTFS", "FAT32", "ext4", "XFS", "ZFS", "btrfs");

private readonly Lazy<Faker<Disk>> _lazyDiskFaker = new(() =>
new Faker<Disk>().UseSeed(GetFakerSeed())
.RuleFor(disk => disk.Manufacturer, faker => faker.Company.CompanyName())
.RuleFor(disk => disk.SerialCode, faker => faker.System.ApplePushToken()));

private readonly Lazy<Faker<Partition>> _lazyPartitionFaker = new(() =>
new Faker<Partition>().UseSeed(GetFakerSeed())
.RuleFor(partition => partition.MountPoint, faker => faker.System.DirectoryPath())
.RuleFor(partition => partition.FileSystem, faker => faker.PickRandom(KnownFileSystems))
.RuleFor(partition => partition.CapacityInBytes, faker => faker.Random.ULong(OneGigabyte * 50, OneGigabyte * 100))
.RuleFor(partition => partition.FreeSpaceInBytes, faker => faker.Random.ULong(OneGigabyte * 10, OneGigabyte * 40)));

public Faker<Disk> Disk => _lazyDiskFaker.Value;
public Faker<Partition> Partition => _lazyPartitionFaker.Value;
}
}
Loading

0 comments on commit 19110ee

Please sign in to comment.