Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
da41504
Migrate DynamoDB client to generated serialization and add typed quer…
Im5tu Mar 7, 2026
3abb032
Add typed write path with DynamoItemWriter and PutItemAsync<T>
Im5tu Mar 7, 2026
5d209e5
Add typed BatchGetItem and TransactGetItems with auto-pagination
Im5tu Mar 7, 2026
fff0845
Add DynamoDB typed API usage documentation
Im5tu Mar 7, 2026
ed0e82c
Adapt typed DynamoDB API to work without Goa.Json.Generator
Im5tu Mar 10, 2026
8c5e202
Merge main into perf/typed-dynamo, resolve TypedExtensionTests conflict
Im5tu Mar 10, 2026
91df8dd
fix: Correct TransactGetItemRequest type name in docs
Im5tu Mar 11, 2026
4ae7895
fix: Replace string.IsNullOrEmpty with null check in JsonMapperGenerator
Im5tu Mar 11, 2026
8f9bb98
fix: Throw DynamoPaginationException in typed pagination methods
Im5tu Mar 11, 2026
66ec988
fix: Preserve error metadata in HandleTypedErrorAsync
Im5tu Mar 11, 2026
7490569
fix: Parse UnprocessedKeys in typed BatchGetItemResponse
Im5tu Mar 11, 2026
faa2e11
Merge main into perf/typed-dynamo
Im5tu Mar 11, 2026
6708b7b
fix: Add missing JsonPropertyName attributes to B and BS properties
Im5tu Mar 11, 2026
b989a0a
Merge branch 'main' into perf/typed-dynamo
Im5tu Mar 11, 2026
1098d84
chore: Remove DynamoDB optimization and typed API docs
Im5tu Mar 11, 2026
5c59b2f
fix: Handle NULL token for non-nullable strings in generated JSON mapper
Im5tu Mar 11, 2026
7f3247e
fix: Remove dead error enrichment in DynamoServiceClient
Im5tu Mar 11, 2026
32e1b93
fix: Correct response model types for PutItem, Query, and Scan
Im5tu Mar 11, 2026
ae94472
fix: Add exponential backoff with jitter for BatchGetItem retries
Im5tu Mar 11, 2026
23a29cd
fix: Correct response model types for single-item and batch operations
Im5tu Mar 11, 2026
d44fc23
fix: Add class constraint to GetItemAsync<T> for correct null semantics
Im5tu Mar 11, 2026
a7434af
fix: Mark registry cache fields as volatile for cross-thread visibility
Im5tu Mar 11, 2026
b042edf
fix: Add missing JsonPropertyName attributes for serialization resili…
Im5tu Mar 11, 2026
5d69e69
Merge remote-tracking branch 'origin/main' into perf/typed-dynamo
Im5tu Mar 12, 2026
e4237fb
fix: Update TypedExtensionTests to use AttributeValue factory methods
Im5tu Mar 12, 2026
1f00580
feat: Enable typed API benchmark for 100-item query
Im5tu Mar 12, 2026
868730b
refactor: Combine BenchmarkItem into BenchmarkEntity with dual attrib…
Im5tu Mar 12, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Microsoft.CodeAnalysis;
using Goa.Clients.Dynamo.Generator.Models;

namespace Goa.Clients.Dynamo.Generator.Attributes;

/// <summary>
/// Handles the DynamoConverterAttribute.
/// </summary>
public class DynamoConverterAttributeHandler : IAttributeHandler
{
public string AttributeTypeName => "Goa.Clients.Dynamo.DynamoConverterAttribute";

public bool CanHandle(AttributeData attributeData)
{
return attributeData.AttributeClass?.ToDisplayString() == AttributeTypeName;
}

public AttributeInfo? ParseAttribute(AttributeData attributeData)
{
if (!CanHandle(attributeData))
{
return null;
}

// Extract converter type from constructor arg
if (attributeData.ConstructorArguments.Length > 0 &&
attributeData.ConstructorArguments[0].Value is INamedTypeSymbol converterType)
{
return new DynamoConverterAttributeInfo
{
AttributeData = attributeData,
AttributeTypeName = AttributeTypeName,
ConverterTypeName = converterType.ToDisplayString()
};
}

return null;
}

public void ValidateAttribute(AttributeInfo attributeInfo, ISymbol symbol, Action<Diagnostic> reportDiagnostic)
{
// No additional validation needed at this stage
}
Comment on lines +40 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate converter shape before codegen.

JsonMapperGenerator later emits new {converter}.Write(...) and new {converter}.Read(...). With ValidateAttribute empty, a converter missing the expected API or a public parameterless ctor only shows up as broken generated code instead of a diagnostic on the annotated symbol.

}
1,490 changes: 1,490 additions & 0 deletions src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Goa.Clients.Dynamo.Generator.Models;

namespace Goa.Clients.Dynamo.Generator.CodeGeneration;

/// <summary>
/// Generates a static registration class for all concrete DynamoDB model types.
/// Consumers call <c>DynamoReaderRegistration.Initialize()</c> once at startup
/// to trigger the static constructor, which registers all types with DynamoItemReaderRegistry.
/// </summary>
public class ReaderRegistrationGenerator : ICodeGenerator
{
public string GenerateCode(IEnumerable<DynamoTypeInfo> types, GenerationContext context)
{
var registrableTypes = types.Where(ShouldRegister).ToList();

if (!registrableTypes.Any())
return string.Empty;

var typesByNamespace = registrableTypes.GroupBy(t => t.Namespace).Where(x => x.Any()).ToList();

if (!typesByNamespace.Any())
return string.Empty;

// Use the first namespace as the containing namespace for the registration class
var targetNamespace = typesByNamespace.First().Key;
if (string.IsNullOrEmpty(targetNamespace))
targetNamespace = "Generated";

var builder = new CodeBuilder();
builder.AppendLine("// <auto-generated/>");
builder.AppendLine("#nullable enable");
builder.AppendLine("using Goa.Clients.Dynamo;");
builder.AppendLine();
builder.AppendLine($"namespace {targetNamespace}");
builder.OpenBrace();
{
builder.OpenBraceWithLine("internal static class DynamoReaderRegistration");
{
builder.OpenBraceWithLine("static DynamoReaderRegistration()");
{
foreach (var type in registrableTypes)
{
var normalizedName = NamingHelpers.NormalizeTypeName(type.Name);
builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.ReadFromJson);");
builder.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.WriteToJson);");
Comment on lines +24 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fully qualify mapper references before registering cross-namespace models.

This generator emits one registration class in the first namespace, but JsonMapperGenerator emits a separate DynamoJsonMapper per namespace. For any registrable type outside targetNamespace, Lines 44-45 resolve against the wrong mapper class, so multi-namespace model sets can fail to compile.

🛠️ Suggested direction
                     foreach (var type in registrableTypes)
                     {
                         var normalizedName = NamingHelpers.NormalizeTypeName(type.Name);
-                        builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.ReadFromJson);");
-                        builder.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.WriteToJson);");
+                        var mapperNamespace = string.IsNullOrEmpty(type.Namespace) ? "Generated" : type.Namespace;
+                        builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(global::{mapperNamespace}.DynamoJsonMapper.{normalizedName}.ReadFromJson);");
+                        builder.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(global::{mapperNamespace}.DynamoJsonMapper.{normalizedName}.WriteToJson);");
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Use the first namespace as the containing namespace for the registration class
var targetNamespace = typesByNamespace.First().Key;
if (string.IsNullOrEmpty(targetNamespace))
targetNamespace = "Generated";
var builder = new CodeBuilder();
builder.AppendLine("// <auto-generated/>");
builder.AppendLine("#nullable enable");
builder.AppendLine("using Goa.Clients.Dynamo;");
builder.AppendLine();
builder.AppendLine($"namespace {targetNamespace}");
builder.OpenBrace();
{
builder.OpenBraceWithLine("internal static class DynamoReaderRegistration");
{
builder.OpenBraceWithLine("static DynamoReaderRegistration()");
{
foreach (var type in registrableTypes)
{
var normalizedName = NamingHelpers.NormalizeTypeName(type.Name);
builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.ReadFromJson);");
builder.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.WriteToJson);");
// Use the first namespace as the containing namespace for the registration class
var targetNamespace = typesByNamespace.First().Key;
if (string.IsNullOrEmpty(targetNamespace))
targetNamespace = "Generated";
var builder = new CodeBuilder();
builder.AppendLine("// <auto-generated/>");
builder.AppendLine("#nullable enable");
builder.AppendLine("using Goa.Clients.Dynamo;");
builder.AppendLine();
builder.AppendLine($"namespace {targetNamespace}");
builder.OpenBrace();
{
builder.OpenBraceWithLine("internal static class DynamoReaderRegistration");
{
builder.OpenBraceWithLine("static DynamoReaderRegistration()");
{
foreach (var type in registrableTypes)
{
var normalizedName = NamingHelpers.NormalizeTypeName(type.Name);
var mapperNamespace = string.IsNullOrEmpty(type.Namespace) ? "Generated" : type.Namespace;
builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(global::{mapperNamespace}.DynamoJsonMapper.{normalizedName}.ReadFromJson);");
builder.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(global::{mapperNamespace}.DynamoJsonMapper.{normalizedName}.WriteToJson);");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs`
around lines 24 - 45, The registration code uses unqualified DynamoJsonMapper
references which fail when a registrable type lives in a different namespace
than the generated DynamoReaderRegistration; update the generation loop in
ReaderRegistrationGenerator (where registrableTypes are iterated and
NamingHelpers.NormalizeTypeName is used) to produce fully-qualified mapper
references: if type.Namespace != targetNamespace emit something like
global::{type.Namespace}.DynamoJsonMapper.{normalizedName} (otherwise keep
DynamoJsonMapper.{normalizedName}), and use that qualified identifier in the
DynamoItemReaderRegistry.Register and DynamoItemWriterRegistry.Register calls so
cross-namespace models reference the correct mapper class.

}
}
builder.CloseBrace();
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine("/// Ensures all DynamoDB model readers are registered. Call once during application startup.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static void Initialize() { }");
}
builder.CloseBrace();
}
builder.CloseBrace();

return builder.ToString();
}

private bool ShouldRegister(DynamoTypeInfo type)
{
if (type.IsAbstract)
return false;

return type.Attributes.Any(a => a is DynamoModelAttributeInfo) ||
HasInheritedDynamoModel(type);
}

private bool HasInheritedDynamoModel(DynamoTypeInfo type)
{
var current = type.BaseType;
while (current != null)
{
if (current.Attributes.Any(a => a is DynamoModelAttributeInfo))
return true;

current = current.BaseType;
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ private static void Execute(Compilation compilation, ImmutableArray<INamedTypeSy
var keyFactoryGenerator = new KeyFactoryGenerator(typeHandlerRegistry);
var mapperGenerator = new MapperGenerator(typeHandlerRegistry);
var extensionGenerator = new ExtensionGenerator(autoGenerateExtensions);
var jsonMapperGenerator = new JsonMapperGenerator(typeHandlerRegistry);
var readerRegistrationGenerator = new ReaderRegistrationGenerator();

var keyFactoryCode = keyFactoryGenerator.GenerateCode(analyzedTypes, generationContext);
var mapperCode = mapperGenerator.GenerateCode(analyzedTypes, generationContext);
Expand All @@ -124,6 +126,15 @@ private static void Execute(Compilation compilation, ImmutableArray<INamedTypeSy
{
context.AddSource("DynamoExtensions.g.cs", SourceText.From(extensionCode, Encoding.UTF8));
}

var jsonMapperCode = jsonMapperGenerator.GenerateCode(analyzedTypes, generationContext);
var readerRegistrationCode = readerRegistrationGenerator.GenerateCode(analyzedTypes, generationContext);

if (!string.IsNullOrEmpty(jsonMapperCode))
context.AddSource("DynamoJsonMapper.g.cs", SourceText.From(jsonMapperCode, Encoding.UTF8));

if (!string.IsNullOrEmpty(readerRegistrationCode))
context.AddSource("DynamoReaderRegistration.g.cs", SourceText.From(readerRegistrationCode, Encoding.UTF8));
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Goa.Clients.Dynamo.Generator.Models;

/// <summary>
/// Represents DynamoConverter attribute information.
/// </summary>
public class DynamoConverterAttributeInfo : AttributeInfo
{
public string ConverterTypeName { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public ITypeSymbol UnderlyingType
/// For non-dictionary types, returns null.
/// </summary>
public (ITypeSymbol KeyType, ITypeSymbol ValueType)? DictionaryTypes { get; set; }

/// <summary>
/// The fully qualified type name of a custom DynamoConverter for this property, if any.
/// </summary>
public string? ConverterTypeName { get; set; }

/// <summary>
/// Gets the effective name for this property in DynamoDB records.
Expand Down
126 changes: 125 additions & 1 deletion src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,70 @@ public static async IAsyncEnumerable<DynamoRecord> ScanAllAsync(this IDynamoClie
while (true);
}

/// <summary>
/// Executes a typed DynamoDB Query operation with automatic pagination using an async iterator.
/// </summary>
public static async IAsyncEnumerable<T> QueryAllAsync<T>(this IDynamoClient client, string tableName, DynamoItemReader<T> itemReader, Action<QueryBuilder> builder, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var _builder = new QueryBuilder(tableName);
builder(_builder);
var request = _builder.Build();

do
{
var result = await client.QueryAsync(request, itemReader, cancellationToken);
if (result.IsError)
{
throw new DynamoPaginationException(result.FirstError);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

foreach (var item in result.Value.Items)
{
yield return item;
}

if (!result.Value.HasMoreResults)
{
break;
}

request.ExclusiveStartKey = result.Value.LastEvaluatedKey;
}
while (true);
}

/// <summary>
/// Executes a typed DynamoDB Scan operation with automatic pagination using an async iterator.
/// </summary>
public static async IAsyncEnumerable<T> ScanAllAsync<T>(this IDynamoClient client, string tableName, DynamoItemReader<T> itemReader, Action<ScanBuilder> builder, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var _builder = new ScanBuilder(tableName);
builder(_builder);
var request = _builder.Build();

do
{
var result = await client.ScanAsync(request, itemReader, cancellationToken);
if (result.IsError)
{
throw new DynamoPaginationException(result.FirstError);
}

foreach (var item in result.Value.Items)
{
yield return item;
}

if (!result.Value.HasMoreResults)
{
break;
}

request.ExclusiveStartKey = result.Value.LastEvaluatedKey;
}
while (true);
}

/// <summary>
/// Executes a DynamoDB BatchGetItem operation with automatic retry of unprocessed keys using an async iterator.
/// </summary>
Expand All @@ -227,9 +291,11 @@ public static async IAsyncEnumerable<DynamoRecord> ScanAllAsync(this IDynamoClie
/// <returns>An async enumerable that yields items from all tables in the batch get result, retrying unprocessed keys.</returns>
public static async IAsyncEnumerable<KeyValuePair<string, DynamoRecord>> BatchGetAllAsync(this IDynamoClient client, Action<BatchGetItemBuilder> builder, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
const int maxAttempts = 10;
var _builder = new BatchGetItemBuilder();
builder(_builder);
var request = _builder.Build();
int attempts = 0;

do
{
Expand All @@ -252,8 +318,66 @@ public static async IAsyncEnumerable<KeyValuePair<string, DynamoRecord>> BatchGe
break;
}

// Retry with unprocessed keys
if (attempts >= maxAttempts)
{
throw new DynamoPaginationException(
ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts."));
}

// Retry with unprocessed keys using exponential backoff with jitter
request.RequestItems = result.Value.UnprocessedKeys!;
var baseDelay = Math.Min(100 * (1 << attempts), 25_600);
var jitter = Random.Shared.Next(0, baseDelay);
await Task.Delay(baseDelay + jitter, cancellationToken);
attempts++;
}
while (true);
}

/// <summary>
/// Executes a typed DynamoDB BatchGetItem operation with automatic retry of unprocessed keys using an async iterator.
/// </summary>
public static async IAsyncEnumerable<KeyValuePair<string, T>> BatchGetAllAsync<T>(this IDynamoClient client, DynamoItemReader<T> itemReader, Action<BatchGetItemBuilder> builder, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
const int maxAttempts = 10;
var _builder = new BatchGetItemBuilder();
builder(_builder);
var request = _builder.Build();
int attempts = 0;

do
{
var result = await client.BatchGetItemAsync(request, itemReader, cancellationToken);
if (result.IsError)
{
throw new DynamoPaginationException(result.FirstError);
}

foreach (var tableResponse in result.Value.Responses)
{
foreach (var item in tableResponse.Value)
{
yield return new KeyValuePair<string, T>(tableResponse.Key, item);
}
}

if (!result.Value.HasUnprocessedKeys)
{
break;
}

if (attempts >= maxAttempts)
{
throw new DynamoPaginationException(
ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts."));
}

// Retry with unprocessed keys using exponential backoff with jitter
request.RequestItems = result.Value.UnprocessedKeys!;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var baseDelay = Math.Min(100 * (1 << attempts), 25_600);
var jitter = Random.Shared.Next(0, baseDelay);
await Task.Delay(baseDelay + jitter, cancellationToken);
attempts++;
}
while (true);
}
Expand Down
28 changes: 28 additions & 0 deletions src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Goa.Clients.Dynamo;

/// <summary>
/// Registry for DynamoItemReader delegates, populated by source generators.
/// </summary>
public static class DynamoItemReaderRegistry
{
/// <summary>
/// Registers a reader delegate for the specified type.
/// </summary>
/// <typeparam name="T">The type the reader deserializes.</typeparam>
/// <param name="reader">The reader delegate.</param>
public static void Register<T>(DynamoItemReader<T> reader) => Cache<T>.Reader = reader;

/// <summary>
/// Gets the registered reader delegate for the specified type.
/// </summary>
/// <typeparam name="T">The type to get the reader for.</typeparam>
/// <returns>The registered reader delegate.</returns>
/// <exception cref="InvalidOperationException">Thrown when no reader is registered for the type.</exception>
public static DynamoItemReader<T> Get<T>() => Cache<T>.Reader
?? throw new InvalidOperationException($"No DynamoItemReader registered for {typeof(T).Name}. Ensure the type has [DynamoModel] attribute and the source generator has run.");

private static class Cache<T>
{
public static volatile DynamoItemReader<T>? Reader;
}
}
28 changes: 28 additions & 0 deletions src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Goa.Clients.Dynamo;

/// <summary>
/// Registry for DynamoItemWriter delegates, populated by source generators.
/// </summary>
public static class DynamoItemWriterRegistry
{
/// <summary>
/// Registers a writer delegate for the specified type.
/// </summary>
/// <typeparam name="T">The type the writer serializes.</typeparam>
/// <param name="writer">The writer delegate.</param>
public static void Register<T>(DynamoItemWriter<T> writer) => Cache<T>.Writer = writer;

/// <summary>
/// Gets the registered writer delegate for the specified type.
/// </summary>
/// <typeparam name="T">The type to get the writer for.</typeparam>
/// <returns>The registered writer delegate.</returns>
/// <exception cref="InvalidOperationException">Thrown when no writer is registered for the type.</exception>
public static DynamoItemWriter<T> Get<T>() => Cache<T>.Writer
?? throw new InvalidOperationException($"No DynamoItemWriter registered for {typeof(T).Name}. Ensure the type has [DynamoModel] attribute and the source generator has run.");

private static class Cache<T>
{
public static volatile DynamoItemWriter<T>? Writer;
}
}
Loading
Loading