Skip to content

Commit

Permalink
Implement optimistic locking scale-tone#8
Browse files Browse the repository at this point in the history
* No more bulk operations for CUD, using async tasks instead
* Removing entities also respects version field
* Update caching behavior to handle partial failure in optimistic locking scenario
  • Loading branch information
Daniel Jurek committed Mar 24, 2017
1 parent e337463 commit ac4365d
Show file tree
Hide file tree
Showing 14 changed files with 338 additions and 98 deletions.
3 changes: 3 additions & 0 deletions Sources/Linq2DynamoDb.DataContext.Tests/Entities/Book.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public class Book : EntityBase
[DynamoDBProperty(typeof(StringTimeSpanDictionaryConverter))]
public IDictionary<string, TimeSpan> FilmsBasedOnBook { get; set; }

[DynamoDBVersion]
public int? VersionNumber { get; set; }

public enum Popularity
{
Low,
Expand Down
3 changes: 3 additions & 0 deletions Sources/Linq2DynamoDb.DataContext.Tests/Entities/BookPoco.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public class BookPoco {
[DynamoDBProperty(typeof(StringTimeSpanDictionaryConverter))]
public IDictionary<string, TimeSpan> FilmsBasedOnBook { get; set; }

[DynamoDBVersion]
public int? VersionNumber { get; set; }

public enum Popularity {
Low,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,18 @@ public void DataContext_EntityCreation_PersistsRecordToDynamoDb()
Assert.IsNotNull(storedBook);
}

[Ignore("This behavior is currently expected. SubmitChanges() uses DocumentBatchWrite, which only supports PUT operations, which by default replaces existing entities")]
[Test]
[ExpectedException(typeof(InvalidOperationException), ExpectedMessage = "cannot be added, because entity with that key already exists", MatchType = MessageMatch.Contains)]
[ExpectedException(typeof(AggregateException))]
public void DataContext_EntityCreation_ThrowsExceptionWhenEntityAlreadyExistsInDynamoDbButWasNeverQueriedInCurrentContext()
{
var book = BooksHelper.CreateBook(popularityRating: Book.Popularity.Average);
var persistedBook = BooksHelper.CreateBook(popularityRating: Book.Popularity.Average);

book.PopularityRating = Book.Popularity.High;
var bookCopy = BooksHelper.CreateBook(name: persistedBook.Name, publishYear: persistedBook.PublishYear, persistToDynamoDb: false);

bookCopy.PopularityRating = Book.Popularity.High;

var booksTable = this.Context.GetTable<Book>();
booksTable.InsertOnSubmit(book);
booksTable.InsertOnSubmit(bookCopy);
this.Context.SubmitChanges();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Linq2DynamoDb.DataContext.Tests.Entities;
using Linq2DynamoDb.DataContext.Tests.Helpers;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Linq2DynamoDb.DataContext.Tests.EntityManagementTests
{
Expand Down Expand Up @@ -50,28 +52,7 @@ public void DataContext_EntityModification_UpdateRecordWithNewArray()

CollectionAssert.AreEquivalent(storedBook.RentingHistory, storedBookAfterModification.RentingHistory);
}

[Ignore("This behavior is currently expected. SubmitChanges() uses DocumentBatchWrite, which only supports PUT operations with default 'replace' behavior")]
[Test]
public void DataContext_EntityModification_UpdateShouldNotAffectFieldsModifiedFromOutside()
{
var book = BooksHelper.CreateBook(popularityRating: Book.Popularity.Average, persistToDynamoDb: false);

var booksTable = this.Context.GetTable<Book>();
booksTable.InsertOnSubmit(book);
this.Context.SubmitChanges();

// Update record from outside of DataTable
BooksHelper.CreateBook(book.Name, book.PublishYear, numPages: 15);

book.PopularityRating = Book.Popularity.High;
this.Context.SubmitChanges();

var storedBook = booksTable.Find(book.Name, book.PublishYear);
Assert.AreEqual(book.PopularityRating, storedBook.PopularityRating, "Record was not updated");
Assert.AreEqual(book.NumPages, 15, "Update has erased changes from outside");
}


[Test]
public void DataContext_UpdateEntity_UpdatesRecordWhenOldRecordIsNull()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,6 @@ public void DataContext_EntityCreation_PersistsRecordToDynamoDb()
Assert.IsNotNull(storedBookPoco);
}

[Ignore("This behavior is currently expected. SubmitChanges() uses DocumentBatchWrite, which only supports PUT operations, which by default replaces existing entities")]
[Test]
[ExpectedException(typeof(InvalidOperationException), ExpectedMessage = "cannot be added, because entity with that key already exists", MatchType = MessageMatch.Contains)]
public void DataContext_EntityCreation_ThrowsExceptionWhenEntityAlreadyExistsInDynamoDbButWasNeverQueriedInCurrentContext()
{
var book = BookPocosHelper.CreateBookPoco(popularityRating: BookPoco.Popularity.Average);

book.PopularityRating = BookPoco.Popularity.High;

var booksTable = this.Context.GetTable<BookPoco>();
booksTable.InsertOnSubmit(book);
this.Context.SubmitChanges();
}

[Test]
[ExpectedException(typeof(InvalidOperationException), ExpectedMessage = "cannot be added, because entity with that key already exists", MatchType = MessageMatch.Contains)]
public void DataContext_EntityCreation_ThrowsExceptionWhenTryingToAddSameEntityTwice()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,6 @@ public void DataContext_EntityModification_UpdateRecordWithNewArray()
CollectionAssert.AreEquivalent(storedBookPoco.RentingHistory, storedBookPocoAfterModification.RentingHistory);
}

[Ignore("This behavior is currently expected. SubmitChanges() uses DocumentBatchWrite, which only supports PUT operations with default 'replace' behavior")]
[Test]
public void DataContext_EntityModification_UpdateShouldNotAffectFieldsModifiedFromOutside()
{
var book = BookPocosHelper.CreateBookPoco(popularityRating: BookPoco.Popularity.Average, persistToDynamoDb: false);

var booksTable = this.Context.GetTable<BookPoco>();
booksTable.InsertOnSubmit(book);
this.Context.SubmitChanges();

// Update record from outside of DataTable
BookPocosHelper.CreateBookPoco(book.Name, book.PublishYear, numPages: 15);

book.PopularityRating = BookPoco.Popularity.High;
this.Context.SubmitChanges();

var storedBookPoco = booksTable.Find(book.Name, book.PublishYear);
Assert.AreEqual(book.PopularityRating, storedBookPoco.PopularityRating, "Record was not updated");
Assert.AreEqual(book.NumPages, 15, "Update has erased changes from outside");
}

[Test]
public void DataContext_UpdateEntity_UpdatesRecordWhenOldRecordIsNull()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using Linq2DynamoDb.DataContext.Tests.Entities;
using Linq2DynamoDb.DataContext.Tests.Helpers;
using NUnit.Framework;
using System;
using System.Collections.Generic;

namespace Linq2DynamoDb.DataContext.Tests.EntityManagementTests.Versioning
{
[TestFixture]
class EntityVersioningTests : DataContextTestBase
{
public override void SetUp()
{
}

public override void TearDown()
{
}

[Test]
[ExpectedException(typeof(AggregateException))]
public void DataContext_UpdateEntity_Does_OptimisticLocking()
{
var contextA = TestConfiguration.GetDataContext();
var contextB = TestConfiguration.GetDataContext();

var originalBook = BooksHelper.CreateBook(popularityRating: Book.Popularity.Low, rentingHistory: null);
var booksTableA = contextA.GetTable<Book>();
var booksTableB = contextB.GetTable<Book>();

// Read the same entry from the database into two contexts
var retrievedBookA = booksTableA.Find(originalBook.Name, originalBook.PublishYear);
var retrievedBookB = booksTableB.Find(originalBook.Name, originalBook.PublishYear);

// Mutate a property on instance A and persist
retrievedBookA.PopularityRating = Book.Popularity.Average;
contextA.SubmitChanges();

// Mutate a property on instance B (unaware of changes to A)
retrievedBookB.RentingHistory = new List<string> { "history element" };
contextB.SubmitChanges();
}

[Test]
[ExpectedException(typeof(AggregateException))]
public void DataContext_AddEntity_DoesNotOverwrite_ExistingVersionedEntity()
{
var contextA = TestConfiguration.GetDataContext();
var contextB = TestConfiguration.GetDataContext();

var tableA = contextA.GetTable<Book>();
var tableB = contextB.GetTable<Book>();

var bookA = BooksHelper.CreateBook(name: "A Tale of Two Books", publishYear: 0, persistToDynamoDb: false);
var bookB = BooksHelper.CreateBook(name: "A Tale of Two Books", publishYear: 0, persistToDynamoDb: false);


tableA.InsertOnSubmit(bookA);
contextA.SubmitChanges();

tableB.InsertOnSubmit(bookB);
contextB.SubmitChanges();
}

[Test]
[ExpectedException(typeof(AggregateException))]
public void DataContext_RemoveEntity_RespectsVersionConstraint()
{
var book = BooksHelper.CreateBook(numPages: 5, persistToDynamoDb: false);

var contextA = TestConfiguration.GetDataContext();
var contextB = TestConfiguration.GetDataContext();

var tableA = contextA.GetTable<Book>();
var tableB = contextB.GetTable<Book>();

// Insert the book in Context A
tableA.InsertOnSubmit(book);
contextA.SubmitChanges();

// Find and modify the book in Context B
var retrievedBook = tableB.Find(book.Name, book.PublishYear);
retrievedBook.NumPages = 10;
contextB.SubmitChanges();

// Try to delete the book from Context A
tableA.RemoveOnSubmit(book);
contextA.SubmitChanges();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static void CleanSession()
{
Logger.DebugFormat("Removing {0} records from DynamoDb", _recordsForCleanup.Count);

Parallel.ForEach(_recordsForCleanup, book => PersistenceContext.Delete(book));
Parallel.ForEach(_recordsForCleanup, book => PersistenceContext.Delete(book, new DynamoDBOperationConfig { SkipVersionCheck = true, ConsistentRead = true }));

_recordsForCleanup = new ConcurrentQueue<Book>();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
<Compile Include="EntityManagementTests\Poco\PocoCreationTests.cs" />
<Compile Include="EntityManagementTests\Poco\PocoModificationTests.cs" />
<Compile Include="EntityManagementTests\Poco\PocoRemovalTests.cs" />
<Compile Include="EntityManagementTests\Versioning\EntityVersioningTests.cs" />
<Compile Include="Helpers\BookPocoHelper.cs" />
<Compile Include="Helpers\BooksHelper.cs" />
<Compile Include="Helpers\MemcachedController.cs" />
Expand Down Expand Up @@ -140,7 +141,9 @@
<SubType>Designer</SubType>
</None>
</ItemGroup>
<ItemGroup />
<ItemGroup>
<Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
Expand Down
2 changes: 2 additions & 0 deletions Sources/Linq2DynamoDb.DataContext/EntityProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ public Document GetDocumentIfDirty()

public object Entity { get { return this.GetTransparentProxy(); } }

public Document AsDocument() { return _document; }

public void Commit() {}
}
}
Expand Down
50 changes: 49 additions & 1 deletion Sources/Linq2DynamoDb.DataContext/EntityWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Linq;
using Amazon.DynamoDBv2.DocumentModel;
using Linq2DynamoDb.DataContext.Utils;
using System.Reflection;
using Amazon.DynamoDBv2.DataModel;

namespace Linq2DynamoDb.DataContext
{
Expand All @@ -14,6 +16,31 @@ internal class EntityWrapper : IEntityWrapper
private readonly IEntityKeyGetter _keyGetter;
private Document _doc, _newDoc;

private PropertyInfo _entityVersionNumberProperty;
private bool _hasResolvedEntityVersionNumberProperty;

/// <summary>
/// Gets PropertyInfo for the entity's property that has [DynamoDBVersion]
/// attribute or returns null if there is none.
/// </summary>
private PropertyInfo EntityVersionNumberProperty {
get {
if (!_hasResolvedEntityVersionNumberProperty) {
_entityVersionNumberProperty = Entity
.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(property =>
property
.GetCustomAttributes(typeof(DynamoDBVersionAttribute), true)
.SingleOrDefault() != null
).SingleOrDefault();

_hasResolvedEntityVersionNumberProperty = true;
}
return _entityVersionNumberProperty;
}
}

internal EntityWrapper(object entity, Func<object, Document> conversionFunctor, IEntityKeyGetter keyGetter)
{
this.Entity = entity;
Expand All @@ -38,14 +65,16 @@ internal EntityWrapper(Document doc, Type entityType, Func<object, Document> con
/// </summary>
internal bool IsCommited { get; private set; }

public Document AsDocument() { return this._conversionFunctor(this.Entity); }

/// <summary>
/// Returns a new document, if the entity was modified since the last call to this method.
/// Otherwise returns null.
/// </summary>
/// <returns></returns>
public Document GetDocumentIfDirty()
{
this._newDoc = this._conversionFunctor(this.Entity);
this._newDoc = AsDocument();

if (this._doc == null)
{
Expand Down Expand Up @@ -101,9 +130,28 @@ public void Commit()
{
this._doc = this._newDoc;
this._newDoc = null;
this.UpdateEntityVersionNumber();
this.IsCommited = true;
}

/// <summary>
/// Sets the value of the Entity's propety that has the DynamoDBVersionAttribute to
/// the value in _doc. When adding/updating the document the version number will
/// change in the _doc on the way to DynamoDB
/// </summary>
private void UpdateEntityVersionNumber()
{
if (EntityVersionNumberProperty == default(PropertyInfo))
{
return;
}

EntityVersionNumberProperty.SetValue(
Entity,
this._doc[EntityVersionNumberProperty.Name].ToObject(EntityVersionNumberProperty.PropertyType)
);
}

#region Redirecting Equals() and GetHashCode() to the underlying entity

public override bool Equals(object obj)
Expand Down
1 change: 1 addition & 0 deletions Sources/Linq2DynamoDb.DataContext/IEntityWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public interface IEntityWrapper
object Entity { get; }
Document GetDocumentIfDirty();
void Commit();
Document AsDocument();
}
}
Loading

0 comments on commit ac4365d

Please sign in to comment.