Skip to content

Issues/224 atomic counter attribute #3809

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

Open
wants to merge 11 commits into
base: development
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions generator/.DevConfigs/887577fc-6ac5-40ca-ac67-4b5808a5db14.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"services": [
{
"serviceName": "DynamoDBv2",
"type": "patch",
"changeLogMessages": [
"Introduce support for the [DynamoDBAtomicCounter] attribute in the DynamoDB Object Persistence Model`"
]
}
]
}
79 changes: 79 additions & 0 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,85 @@ public DynamoDBVersionAttribute(string attributeName)
}
}

/// <summary>
/// Marks a property or field as an atomic counter in DynamoDB.
///
/// This attribute indicates that the associated property or field should be treated as an atomic counter,
/// which can be incremented or decremented directly in DynamoDB during update operations.
/// It is useful for scenarios where you need to maintain a counter that is updated concurrently by multiple clients
/// without conflicts.
///
/// The attribute also allows specifying an alternate attribute name in DynamoDB using the `AttributeName` property,
/// as well as configuring the increment or decrement value (`Delta`) and the starting value (`StartValue`).
/// </summary>
/// <example>
/// Example usage:
/// <code>
/// public class Example
/// {
/// [DynamoDBAtomicCounter]
/// public long Counter { get; set; }
///
/// [DynamoDBAtomicCounter("CustomCounterName", delta: 5, startValue: 100)]
/// public long CustomCounter { get; set; }
/// }
/// </code>
/// In this example:
/// - `Counter` will be treated as an atomic counter with the same name in DynamoDB.
/// - `CustomCounter` will be treated as an atomic counter with the attribute name "CustomCounterName" in DynamoDB,
/// incremented by 5 for each update, and starting with an initial value of 100.
/// </example>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class DynamoDBAtomicCounterAttribute : DynamoDBRenamableAttribute
{
/// <summary>
/// The value to increment (positive) or decrement (negative) the counter with for each update.
/// </summary>
public long Delta { get; }

/// <summary>
/// The starting value of the counter.
/// </summary>
public long StartValue { get; }

/// <summary>
/// Default constructor
/// </summary>
public DynamoDBAtomicCounterAttribute()
: base()
{
Delta = 1;
StartValue = 0;
}

/// <summary>
/// Constructor that specifies an alternate attribute name
/// </summary>
/// <param name="attributeName">
/// Name of attribute to be associated with property or field.
/// </param>
/// <param name="delta">The value to increment (positive) or decrement (negative) the counter with for each update.</param>
/// <param name="startValue">The starting value of the counter.</param>
public DynamoDBAtomicCounterAttribute(string attributeName, long delta, long startValue)
: base(attributeName)
{
Delta = delta;
StartValue = startValue;
}

/// <summary>
/// Constructor that specifies an alternate attribute name
/// </summary>
/// <param name="delta">The value to increment (positive) or decrement (negative) the counter with for each update.</param>
/// <param name="startValue">The starting value of the counter.</param>
public DynamoDBAtomicCounterAttribute(long delta, long startValue)
: base()
{
Delta = delta;
StartValue = startValue;
}
}


/// <summary>
/// DynamoDB property attribute.
Expand Down
71 changes: 59 additions & 12 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
#if AWS_ASYNC_API
using System.Threading.Tasks;

#endif
using Amazon.DynamoDBv2.DocumentModel;
using ThirdParty.RuntimeBackports;
using Expression = Amazon.DynamoDBv2.DocumentModel.Expression;

namespace Amazon.DynamoDBv2.DataModel
{
Expand Down Expand Up @@ -369,22 +372,43 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr
if (storage == null) return;

Table table = GetTargetTable(storage.Config, flatConfig);

var counterConditionExpression = BuildCounterConditionExpression(storage);

Document updateDocument;
Expression versionExpression = null;

var returnValues=counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes;

if ((flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) || !storage.Config.HasVersion)
{
table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), null);
updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig()
{
ReturnValues = returnValues
}, counterConditionExpression);
}
else
{
Document expectedDocument = CreateExpectedDocumentForVersion(storage);
var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig);
SetNewVersion(storage);

var updateItemOperationConfig = new UpdateItemOperationConfig
{
Expected = expectedDocument,
ReturnValues = ReturnValues.None,
ReturnValues = returnValues,
ConditionalExpression = versionExpression,
};
table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig);
PopulateInstance(storage, value, flatConfig);
updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig, counterConditionExpression);
}

if (returnValues==ReturnValues.AllNewAttributes)
{
storage.Document = updateDocument;
}

if (counterConditionExpression == null && versionExpression == null) return;

PopulateInstance(storage, value, flatConfig);
}

#if AWS_ASYNC_API
Expand All @@ -401,23 +425,46 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants
if (storage == null) return;

Table table = GetTargetTable(storage.Config, flatConfig);

var counterConditionExpression = BuildCounterConditionExpression(storage);

Document updateDocument;
Expression versionExpression = null;

var returnValues = counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes;

if (
(flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value)
|| !storage.Config.HasVersion)
{
await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), null, cancellationToken).ConfigureAwait(false);
updateDocument = await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig
{
ReturnValues = returnValues
}, counterConditionExpression, cancellationToken).ConfigureAwait(false);
}
else
{
Document expectedDocument = CreateExpectedDocumentForVersion(storage);
var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig);
SetNewVersion(storage);
await table.UpdateHelperAsync(

updateDocument = await table.UpdateHelperAsync(
storage.Document,
table.MakeKey(storage.Document),
new UpdateItemOperationConfig { Expected = expectedDocument, ReturnValues = ReturnValues.None },
cancellationToken).ConfigureAwait(false);
PopulateInstance(storage, value, flatConfig);
new UpdateItemOperationConfig
{
ReturnValues = returnValues,
ConditionalExpression = versionExpression
}, counterConditionExpression,
cancellationToken)
.ConfigureAwait(false);
}


if (counterConditionExpression == null && versionExpression == null) return;

storage.Document = updateDocument;
PopulateInstance(storage, value, flatConfig);
}
#endif

Expand Down
61 changes: 57 additions & 4 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ private static void IncrementVersion(Type memberType, ref Primitive version)
else if (memberType.IsAssignableFrom(typeof(short))) version = version.AsShort() + 1;
else if (memberType.IsAssignableFrom(typeof(ushort))) version = version.AsUShort() + 1;
}

private static Document CreateExpectedDocumentForVersion(ItemStorage storage)
{
Document document = new Document();
Expand Down Expand Up @@ -117,6 +118,57 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora

#endregion

#region Atomic counters

internal static Expression BuildCounterConditionExpression(ItemStorage storage)
{
var atomicCounters = GetCounterProperties(storage);
Expression counterConditionExpression = null;

if (atomicCounters.Length != 0)
{
counterConditionExpression = CreateUpdateExpressionForCounterProperties(atomicCounters);
}

return counterConditionExpression;
}

private static PropertyStorage[] GetCounterProperties(ItemStorage storage)
{
var counterProperties = storage.Config.BaseTypeStorageConfig.Properties.
Where(propertyStorage => propertyStorage.IsCounter).ToArray();

return counterProperties;
}

private static Expression CreateUpdateExpressionForCounterProperties(PropertyStorage[] counterPropertyStorages)
{
if (counterPropertyStorages.Length == 0) return null;

Expression updateExpression = new Expression();
var asserts = string.Empty;

foreach (var propertyStorage in counterPropertyStorages)
{
string startValueName = $":{propertyStorage.AttributeName}Start";
string deltaValueName = $":{propertyStorage.AttributeName}Delta";
string counterAttributeName = Common.GetAttributeReference(propertyStorage.AttributeName);
asserts += $"{counterAttributeName} = " +
Copy link
Member

Choose a reason for hiding this comment

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

Curious how this compares to Java V2. But if I set the StartValue to 3 and Delta to 5 when I save my first version of the object I get 8. I would expect 3 after the first save and 8 with the second save. If this is consistent with Java we need to make sure the docs on the StartValue and Delta are clear this is how it works.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

first save will be 3 as the value in ExpressionAttributeValues is CounterStartValue-CounterDelta so that the increment can be done correctly. This is the same behavior as in Java V2.

Copy link
Member

Choose a reason for hiding this comment

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

Okay, can you add a comment by the propertyStorage.CounterStartValue - propertyStorage.CounterDelta; line that the propertyStorage.CounterDelta is being subtracted from the initial value to compensate it being added back to the starting value via the delta? Since I missed that detail reviewing the code others in the future will likely miss that as well.

$"if_not_exists({counterAttributeName},{startValueName}) + {deltaValueName} ,";
updateExpression.ExpressionAttributeNames[counterAttributeName] = propertyStorage.AttributeName;
updateExpression.ExpressionAttributeValues[deltaValueName] = propertyStorage.CounterDelta;

//CounterDelta is being subtracted from CounterStartValue to compensate it being added back to the starting value
updateExpression.ExpressionAttributeValues[startValueName] =
propertyStorage.CounterStartValue - propertyStorage.CounterDelta;
}
updateExpression.ExpressionStatement = $"SET {asserts.Substring(0, asserts.Length - 2)}";

return updateExpression;
}

#endregion

#region Table methods

// Retrieves the target table for the specified type
Expand Down Expand Up @@ -392,7 +444,6 @@ private void PopulateInstance(ItemStorage storage, object instance, DynamoDBFlat
{
foreach (PropertyStorage propertyStorage in storageConfig.AllPropertyStorage)
{
string propertyName = propertyStorage.PropertyName;
string attributeName = propertyStorage.AttributeName;

DynamoDBEntry entry;
Expand Down Expand Up @@ -466,8 +517,9 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
{
// if only keys are being serialized, skip non-key properties
// still include version, however, to populate the storage.CurrentVersion field
// and include counter, to populate the storage.CurrentCount field
if (keysOnly && !propertyStorage.IsHashKey && !propertyStorage.IsRangeKey &&
!propertyStorage.IsVersion) continue;
!propertyStorage.IsVersion && !propertyStorage.IsCounter) continue;

string propertyName = propertyStorage.PropertyName;
string attributeName = propertyStorage.AttributeName;
Expand All @@ -481,11 +533,12 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
{
Primitive dbePrimitive = dbe as Primitive;
if (propertyStorage.IsHashKey || propertyStorage.IsRangeKey ||
propertyStorage.IsVersion || propertyStorage.IsLSIRangeKey)
propertyStorage.IsVersion || propertyStorage.IsLSIRangeKey ||
propertyStorage.IsCounter)
{
if (dbe != null && dbePrimitive == null)
throw new InvalidOperationException("Property " + propertyName +
" is a hash key, range key or version property and must be Primitive");
" is a hash key, range key, atomic counter or version property and must be Primitive");
}

document[attributeName] = dbe;
Expand Down
19 changes: 18 additions & 1 deletion sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ internal class PropertyStorage : SimplePropertyStorage
// corresponding IndexNames, if applicable
public List<string> IndexNames { get; set; }

public bool IsCounter { get; set; }

public long CounterDelta { get; set; }

public long CounterStartValue { get; set; }

public void AddIndex(DynamoDBGlobalSecondaryIndexHashKeyAttribute gsiHashKey)
{
AddIndex(new GSI(true, gsiHashKey.AttributeName, gsiHashKey.IndexNames));
Expand Down Expand Up @@ -209,7 +215,10 @@ public GSI(bool isHashKey, string attributeName, params string[] indexNames)
public void Validate(DynamoDBContext context)
{
if (IsVersion)
Utils.ValidateVersionType(MemberType); // no conversion is possible, so type must be a nullable primitive
Utils.ValidateNumericType(MemberType); // no conversion is possible, so type must be a nullable primitive

if (IsCounter)
Utils.ValidateNumericType(MemberType); // no conversion is possible, so type must be a nullable primitive

if (IsHashKey && IsRangeKey)
throw new InvalidOperationException("Property " + PropertyName + " cannot be both hash and range key");
Expand Down Expand Up @@ -958,6 +967,14 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con
if (attribute is DynamoDBVersionAttribute)
propertyStorage.IsVersion = true;

DynamoDBAtomicCounterAttribute counterAttribute = attribute as DynamoDBAtomicCounterAttribute;
if (counterAttribute != null)
{
propertyStorage.IsCounter = true;
propertyStorage.CounterDelta = counterAttribute.Delta;
propertyStorage.CounterStartValue = counterAttribute.StartValue;
}

DynamoDBRenamableAttribute renamableAttribute = attribute as DynamoDBRenamableAttribute;
if (renamableAttribute != null && !string.IsNullOrEmpty(renamableAttribute.AttributeName))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ public void AddSaveItem(T item)

ItemStorage storage = _context.ObjectToItemStorageHelper(item, _storageConfig, _config, keysOnly: false, _config.IgnoreNullValues ?? false);
if (storage == null) return;

Expression conditionExpression = CreateConditionExpressionForVersion(storage);
SetNewVersion(storage);

Expand Down Expand Up @@ -432,7 +433,6 @@ private Expression CreateConditionExpressionForVersion(ItemStorage storage)
DocumentTransaction.TargetTable.IsEmptyStringValueEnabled);
return DynamoDBContext.CreateConditionExpressionForVersion(storage, conversionConfig);
}


private void AddDocumentTransaction(ItemStorage storage, Expression conditionExpression)
{
Expand Down Expand Up @@ -463,7 +463,6 @@ private void AddDocumentTransaction(ItemStorage storage, Expression conditionExp
}
else
{

DocumentTransaction.AddDocumentToPut(storage.Document, new TransactWriteItemOperationConfig
{
ConditionalExpression = conditionExpression,
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ internal static void ValidatePrimitiveType<T>()
ValidatePrimitiveType(typeof(T));
}

internal static void ValidateVersionType(Type memberType)
internal static void ValidateNumericType(Type memberType)
{
if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
(memberType.IsAssignableFrom(typeof(Byte)) ||
Expand Down
Loading