Skip to content

Commit 5fd03bf

Browse files
authored
Atomic counter attribute (#3809)
1 parent c22beae commit 5fd03bf

File tree

13 files changed

+508
-145
lines changed

13 files changed

+508
-145
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"services": [
3+
{
4+
"serviceName": "DynamoDBv2",
5+
"type": "patch",
6+
"changeLogMessages": [
7+
"Introduce support for the [DynamoDBAtomicCounter] attribute in the DynamoDB Object Persistence Model`"
8+
]
9+
}
10+
]
11+
}

sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,85 @@ public DynamoDBVersionAttribute(string attributeName)
254254
}
255255
}
256256

257+
/// <summary>
258+
/// Marks a property or field as an atomic counter in DynamoDB.
259+
///
260+
/// This attribute indicates that the associated property or field should be treated as an atomic counter,
261+
/// which can be incremented or decremented directly in DynamoDB during update operations.
262+
/// It is useful for scenarios where you need to maintain a counter that is updated concurrently by multiple clients
263+
/// without conflicts.
264+
///
265+
/// The attribute also allows specifying an alternate attribute name in DynamoDB using the `AttributeName` property,
266+
/// as well as configuring the increment or decrement value (`Delta`) and the starting value (`StartValue`).
267+
/// </summary>
268+
/// <example>
269+
/// Example usage:
270+
/// <code>
271+
/// public class Example
272+
/// {
273+
/// [DynamoDBAtomicCounter]
274+
/// public long Counter { get; set; }
275+
///
276+
/// [DynamoDBAtomicCounter("CustomCounterName", delta: 5, startValue: 100)]
277+
/// public long CustomCounter { get; set; }
278+
/// }
279+
/// </code>
280+
/// In this example:
281+
/// - `Counter` will be treated as an atomic counter with the same name in DynamoDB.
282+
/// - `CustomCounter` will be treated as an atomic counter with the attribute name "CustomCounterName" in DynamoDB,
283+
/// incremented by 5 for each update, and starting with an initial value of 100.
284+
/// </example>
285+
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
286+
public sealed class DynamoDBAtomicCounterAttribute : DynamoDBRenamableAttribute
287+
{
288+
/// <summary>
289+
/// The value to increment (positive) or decrement (negative) the counter with for each update.
290+
/// </summary>
291+
public long Delta { get; }
292+
293+
/// <summary>
294+
/// The starting value of the counter.
295+
/// </summary>
296+
public long StartValue { get; }
297+
298+
/// <summary>
299+
/// Default constructor
300+
/// </summary>
301+
public DynamoDBAtomicCounterAttribute()
302+
: base()
303+
{
304+
Delta = 1;
305+
StartValue = 0;
306+
}
307+
308+
/// <summary>
309+
/// Constructor that specifies an alternate attribute name
310+
/// </summary>
311+
/// <param name="attributeName">
312+
/// Name of attribute to be associated with property or field.
313+
/// </param>
314+
/// <param name="delta">The value to increment (positive) or decrement (negative) the counter with for each update.</param>
315+
/// <param name="startValue">The starting value of the counter.</param>
316+
public DynamoDBAtomicCounterAttribute(string attributeName, long delta, long startValue)
317+
: base(attributeName)
318+
{
319+
Delta = delta;
320+
StartValue = startValue;
321+
}
322+
323+
/// <summary>
324+
/// Constructor that specifies an alternate attribute name
325+
/// </summary>
326+
/// <param name="delta">The value to increment (positive) or decrement (negative) the counter with for each update.</param>
327+
/// <param name="startValue">The starting value of the counter.</param>
328+
public DynamoDBAtomicCounterAttribute(long delta, long startValue)
329+
: base()
330+
{
331+
Delta = delta;
332+
StartValue = startValue;
333+
}
334+
}
335+
257336

258337
/// <summary>
259338
/// DynamoDB property attribute.

sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616
using System;
1717
using System.Collections.Generic;
1818
using System.Diagnostics.CodeAnalysis;
19+
using System.Linq;
20+
using System.Linq.Expressions;
1921
using System.Threading;
2022
#if AWS_ASYNC_API
2123
using System.Threading.Tasks;
2224

2325
#endif
2426
using Amazon.DynamoDBv2.DocumentModel;
2527
using ThirdParty.RuntimeBackports;
28+
using Expression = Amazon.DynamoDBv2.DocumentModel.Expression;
2629

2730
namespace Amazon.DynamoDBv2.DataModel
2831
{
@@ -369,22 +372,43 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr
369372
if (storage == null) return;
370373

371374
Table table = GetTargetTable(storage.Config, flatConfig);
375+
376+
var counterConditionExpression = BuildCounterConditionExpression(storage);
377+
378+
Document updateDocument;
379+
Expression versionExpression = null;
380+
381+
var returnValues=counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes;
382+
372383
if ((flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) || !storage.Config.HasVersion)
373384
{
374-
table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), null);
385+
updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig()
386+
{
387+
ReturnValues = returnValues
388+
}, counterConditionExpression);
375389
}
376390
else
377391
{
378-
Document expectedDocument = CreateExpectedDocumentForVersion(storage);
392+
var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
393+
versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig);
379394
SetNewVersion(storage);
395+
380396
var updateItemOperationConfig = new UpdateItemOperationConfig
381397
{
382-
Expected = expectedDocument,
383-
ReturnValues = ReturnValues.None,
398+
ReturnValues = returnValues,
399+
ConditionalExpression = versionExpression,
384400
};
385-
table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig);
386-
PopulateInstance(storage, value, flatConfig);
401+
updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig, counterConditionExpression);
387402
}
403+
404+
if (counterConditionExpression == null && versionExpression == null) return;
405+
406+
if (returnValues == ReturnValues.AllNewAttributes)
407+
{
408+
storage.Document = updateDocument;
409+
}
410+
411+
PopulateInstance(storage, value, flatConfig);
388412
}
389413

390414
#if AWS_ASYNC_API
@@ -401,23 +425,48 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants
401425
if (storage == null) return;
402426

403427
Table table = GetTargetTable(storage.Config, flatConfig);
428+
429+
var counterConditionExpression = BuildCounterConditionExpression(storage);
430+
431+
Document updateDocument;
432+
Expression versionExpression = null;
433+
434+
var returnValues = counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes;
435+
404436
if (
405437
(flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value)
406438
|| !storage.Config.HasVersion)
407439
{
408-
await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), null, cancellationToken).ConfigureAwait(false);
440+
updateDocument = await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig
441+
{
442+
ReturnValues = returnValues
443+
}, counterConditionExpression, cancellationToken).ConfigureAwait(false);
409444
}
410445
else
411446
{
412-
Document expectedDocument = CreateExpectedDocumentForVersion(storage);
447+
var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
448+
versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig);
413449
SetNewVersion(storage);
414-
await table.UpdateHelperAsync(
450+
451+
updateDocument = await table.UpdateHelperAsync(
415452
storage.Document,
416453
table.MakeKey(storage.Document),
417-
new UpdateItemOperationConfig { Expected = expectedDocument, ReturnValues = ReturnValues.None },
418-
cancellationToken).ConfigureAwait(false);
419-
PopulateInstance(storage, value, flatConfig);
454+
new UpdateItemOperationConfig
455+
{
456+
ReturnValues = returnValues,
457+
ConditionalExpression = versionExpression
458+
}, counterConditionExpression,
459+
cancellationToken)
460+
.ConfigureAwait(false);
461+
}
462+
463+
if (counterConditionExpression == null && versionExpression == null) return;
464+
465+
if (returnValues == ReturnValues.AllNewAttributes)
466+
{
467+
storage.Document = updateDocument;
420468
}
469+
PopulateInstance(storage, value, flatConfig);
421470
}
422471
#endif
423472

sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ private static void IncrementVersion(Type memberType, ref Primitive version)
7070
else if (memberType.IsAssignableFrom(typeof(short))) version = version.AsShort() + 1;
7171
else if (memberType.IsAssignableFrom(typeof(ushort))) version = version.AsUShort() + 1;
7272
}
73+
7374
private static Document CreateExpectedDocumentForVersion(ItemStorage storage)
7475
{
7576
Document document = new Document();
@@ -117,6 +118,57 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora
117118

118119
#endregion
119120

121+
#region Atomic counters
122+
123+
internal static Expression BuildCounterConditionExpression(ItemStorage storage)
124+
{
125+
var atomicCounters = GetCounterProperties(storage);
126+
Expression counterConditionExpression = null;
127+
128+
if (atomicCounters.Length != 0)
129+
{
130+
counterConditionExpression = CreateUpdateExpressionForCounterProperties(atomicCounters);
131+
}
132+
133+
return counterConditionExpression;
134+
}
135+
136+
private static PropertyStorage[] GetCounterProperties(ItemStorage storage)
137+
{
138+
var counterProperties = storage.Config.BaseTypeStorageConfig.Properties.
139+
Where(propertyStorage => propertyStorage.IsCounter).ToArray();
140+
141+
return counterProperties;
142+
}
143+
144+
private static Expression CreateUpdateExpressionForCounterProperties(PropertyStorage[] counterPropertyStorages)
145+
{
146+
if (counterPropertyStorages.Length == 0) return null;
147+
148+
Expression updateExpression = new Expression();
149+
var asserts = string.Empty;
150+
151+
foreach (var propertyStorage in counterPropertyStorages)
152+
{
153+
string startValueName = $":{propertyStorage.AttributeName}Start";
154+
string deltaValueName = $":{propertyStorage.AttributeName}Delta";
155+
string counterAttributeName = Common.GetAttributeReference(propertyStorage.AttributeName);
156+
asserts += $"{counterAttributeName} = " +
157+
$"if_not_exists({counterAttributeName},{startValueName}) + {deltaValueName} ,";
158+
updateExpression.ExpressionAttributeNames[counterAttributeName] = propertyStorage.AttributeName;
159+
updateExpression.ExpressionAttributeValues[deltaValueName] = propertyStorage.CounterDelta;
160+
161+
//CounterDelta is being subtracted from CounterStartValue to compensate it being added back to the starting value
162+
updateExpression.ExpressionAttributeValues[startValueName] =
163+
propertyStorage.CounterStartValue - propertyStorage.CounterDelta;
164+
}
165+
updateExpression.ExpressionStatement = $"SET {asserts.Substring(0, asserts.Length - 2)}";
166+
167+
return updateExpression;
168+
}
169+
170+
#endregion
171+
120172
#region Table methods
121173

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

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

472524
string propertyName = propertyStorage.PropertyName;
473525
string attributeName = propertyStorage.AttributeName;
@@ -481,11 +533,12 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
481533
{
482534
Primitive dbePrimitive = dbe as Primitive;
483535
if (propertyStorage.IsHashKey || propertyStorage.IsRangeKey ||
484-
propertyStorage.IsVersion || propertyStorage.IsLSIRangeKey)
536+
propertyStorage.IsVersion || propertyStorage.IsLSIRangeKey ||
537+
propertyStorage.IsCounter)
485538
{
486539
if (dbe != null && dbePrimitive == null)
487540
throw new InvalidOperationException("Property " + propertyName +
488-
" is a hash key, range key or version property and must be Primitive");
541+
" is a hash key, range key, atomic counter or version property and must be Primitive");
489542
}
490543

491544
document[attributeName] = dbe;

sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ internal class PropertyStorage : SimplePropertyStorage
149149
// corresponding IndexNames, if applicable
150150
public List<string> IndexNames { get; set; }
151151

152+
public bool IsCounter { get; set; }
153+
154+
public long CounterDelta { get; set; }
155+
156+
public long CounterStartValue { get; set; }
157+
152158
public void AddIndex(DynamoDBGlobalSecondaryIndexHashKeyAttribute gsiHashKey)
153159
{
154160
AddIndex(new GSI(true, gsiHashKey.AttributeName, gsiHashKey.IndexNames));
@@ -209,7 +215,10 @@ public GSI(bool isHashKey, string attributeName, params string[] indexNames)
209215
public void Validate(DynamoDBContext context)
210216
{
211217
if (IsVersion)
212-
Utils.ValidateVersionType(MemberType); // no conversion is possible, so type must be a nullable primitive
218+
Utils.ValidateNumericType(MemberType); // no conversion is possible, so type must be a nullable primitive
219+
220+
if (IsCounter)
221+
Utils.ValidateNumericType(MemberType); // no conversion is possible, so type must be a nullable primitive
213222

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

970+
DynamoDBAtomicCounterAttribute counterAttribute = attribute as DynamoDBAtomicCounterAttribute;
971+
if (counterAttribute != null)
972+
{
973+
propertyStorage.IsCounter = true;
974+
propertyStorage.CounterDelta = counterAttribute.Delta;
975+
propertyStorage.CounterStartValue = counterAttribute.StartValue;
976+
}
977+
961978
DynamoDBRenamableAttribute renamableAttribute = attribute as DynamoDBRenamableAttribute;
962979
if (renamableAttribute != null && !string.IsNullOrEmpty(renamableAttribute.AttributeName))
963980
{

sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ public void AddSaveItem(T item)
225225

226226
ItemStorage storage = _context.ObjectToItemStorageHelper(item, _storageConfig, _config, keysOnly: false, _config.IgnoreNullValues ?? false);
227227
if (storage == null) return;
228+
228229
Expression conditionExpression = CreateConditionExpressionForVersion(storage);
229230
SetNewVersion(storage);
230231

@@ -432,7 +433,6 @@ private Expression CreateConditionExpressionForVersion(ItemStorage storage)
432433
DocumentTransaction.TargetTable.IsEmptyStringValueEnabled);
433434
return DynamoDBContext.CreateConditionExpressionForVersion(storage, conversionConfig);
434435
}
435-
436436

437437
private void AddDocumentTransaction(ItemStorage storage, Expression conditionExpression)
438438
{
@@ -463,7 +463,6 @@ private void AddDocumentTransaction(ItemStorage storage, Expression conditionExp
463463
}
464464
else
465465
{
466-
467466
DocumentTransaction.AddDocumentToPut(storage.Document, new TransactWriteItemOperationConfig
468467
{
469468
ConditionalExpression = conditionExpression,

sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ internal static void ValidatePrimitiveType<T>()
141141
ValidatePrimitiveType(typeof(T));
142142
}
143143

144-
internal static void ValidateVersionType(Type memberType)
144+
internal static void ValidateNumericType(Type memberType)
145145
{
146146
if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
147147
(memberType.IsAssignableFrom(typeof(Byte)) ||

0 commit comments

Comments
 (0)