From 616117c20262cc63a5aa32e4ab1538ad3736e173 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 12:34:14 +0000 Subject: [PATCH 01/34] perf: Seal eligible classes to enable JIT devirtualization Adds the sealed keyword to ~89 classes across Bedrock, Dynamo, and Core libraries that are not abstract, not inherited, and not partial. This enables the JIT to devirtualize and inline method calls on these types. --- .../DynamoConversationStoreConfiguration.cs | 2 +- .../Entities/Conversation.cs | 2 +- .../Entities/ConversationListResult.cs | 2 +- .../Entities/ConversationMessage.cs | 2 +- .../Entities/ConversationMetadata.cs | 2 +- .../Entities/ConversationWithMessages.cs | 2 +- .../Goa.Clients.Bedrock/BedrockServiceClient.cs | 2 +- .../BedrockServiceClientConfiguration.cs | 2 +- .../Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs | 2 +- .../Goa.Clients.Bedrock/Models/ConverseMetrics.cs | 2 +- .../Goa.Clients.Bedrock/Models/DocumentBlock.cs | 4 ++-- .../Models/GuardrailConfiguration.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs | 6 +++--- .../Models/InferenceConfiguration.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/Message.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs | 8 ++++---- .../Models/PerformanceConfiguration.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/Tool.cs | 6 +++--- .../Goa.Clients.Bedrock/Models/ToolConfiguration.cs | 10 +++++----- .../Goa.Clients.Bedrock/Models/ToolResultBlock.cs | 2 +- src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs | 2 +- .../Operations/Converse/ConverseBuilder.cs | 2 +- .../Operations/Converse/ConverseRequest.cs | 6 +++--- .../Operations/Converse/ConverseResponse.cs | 4 ++-- src/Clients/Goa.Clients.Core/XmlApiError.cs | 2 +- src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs | 2 +- src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs | 2 +- .../DynamoServiceClientConfiguration.cs | 2 +- .../Exceptions/DynamoPaginationException.cs | 2 +- .../Exceptions/MissingAttributeException.cs | 2 +- src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs | 2 +- .../GlobalSecondaryIndexAttribute.cs | 2 +- src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs | 2 +- .../Goa.Clients.Dynamo/Models/CapacityDetail.cs | 2 +- .../Goa.Clients.Dynamo/Models/ConsumedCapacity.cs | 2 +- src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs | 2 +- .../Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs | 2 +- .../Operations/Batch/BatchGetItemBuilder.cs | 2 +- .../Operations/Batch/BatchGetItemRequest.cs | 2 +- .../Operations/Batch/BatchGetItemResponse.cs | 2 +- .../Operations/Batch/BatchGetRequestItem.cs | 2 +- .../Operations/Batch/BatchGetTableBuilder.cs | 2 +- .../Operations/Batch/BatchWriteItemBuilder.cs | 2 +- .../Operations/Batch/BatchWriteItemRequest.cs | 2 +- .../Operations/Batch/BatchWriteItemResponse.cs | 2 +- .../Operations/Batch/BatchWriteRequestItem.cs | 2 +- .../Operations/Batch/BatchWriteResult.cs | 2 +- .../Operations/Batch/BatchWriteTableBuilder.cs | 2 +- .../Operations/Batch/DeleteRequest.cs | 2 +- .../Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs | 2 +- src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs | 2 +- .../Operations/DeleteItem/DeleteItemBuilder.cs | 2 +- .../Operations/DeleteItem/DeleteItemRequest.cs | 2 +- .../Operations/DeleteItem/DeleteItemResponse.cs | 2 +- .../Operations/GetItem/GetItemBuilder.cs | 2 +- .../Operations/GetItem/GetItemRequest.cs | 2 +- .../Operations/GetItem/GetItemResponse.cs | 2 +- .../Operations/PutItem/PutItemBuilder.cs | 2 +- .../Operations/PutItem/PutItemRequest.cs | 2 +- .../Operations/PutItem/PutItemResponse.cs | 2 +- .../Operations/Query/QueryRequest.cs | 2 +- .../Operations/Query/QueryResponse.cs | 2 +- .../Goa.Clients.Dynamo/Operations/Query/QueryResult.cs | 4 ++-- .../Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs | 2 +- .../Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs | 2 +- .../Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs | 2 +- .../Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs | 4 ++-- .../Transactions/TransactConditionCheckItem.cs | 2 +- .../Operations/Transactions/TransactDeleteItem.cs | 2 +- .../Operations/Transactions/TransactGetBuilder.cs | 2 +- .../Operations/Transactions/TransactGetItem.cs | 2 +- .../Operations/Transactions/TransactGetItemRequest.cs | 2 +- .../Operations/Transactions/TransactGetItemResponse.cs | 2 +- .../Operations/Transactions/TransactGetRequest.cs | 2 +- .../Operations/Transactions/TransactGetResult.cs | 2 +- .../Operations/Transactions/TransactPutItem.cs | 2 +- .../Operations/Transactions/TransactUpdateItem.cs | 2 +- .../Operations/Transactions/TransactWriteBuilder.cs | 2 +- .../Operations/Transactions/TransactWriteItem.cs | 2 +- .../Transactions/TransactWriteItemResponse.cs | 2 +- .../Operations/Transactions/TransactWriteOperation.cs | 2 +- .../Operations/Transactions/TransactWriteRequest.cs | 2 +- .../Operations/UpdateItem/UpdateItemRequest.cs | 2 +- .../Operations/UpdateItem/UpdateItemResponse.cs | 2 +- .../Goa.Clients.Dynamo/SerializedNameAttribute.cs | 2 +- .../Goa.Clients.Dynamo/UnixTimestampAttribute.cs | 2 +- 89 files changed, 106 insertions(+), 106 deletions(-) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs index 54092c39..b338c978 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Dynamo; /// /// Configuration for the DynamoDB-backed conversation store. /// -public class DynamoConversationStoreConfiguration +public sealed class DynamoConversationStoreConfiguration { /// /// The name of the DynamoDB table to store conversations. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs index df1be89c..ace422fd 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents a conversation with a Bedrock model. /// -public class Conversation +public sealed class Conversation { /// /// The unique identifier for the conversation. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs index fc4fea2f..be86902e 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents the result of listing conversations. /// -public class ConversationListResult +public sealed class ConversationListResult { /// /// The list of conversations. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs index 98a61e7a..12dfa869 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents a message within a conversation. /// -public class ConversationMessage +public sealed class ConversationMessage { /// /// The unique identifier for the message. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs index 8fedd863..c1e5b87d 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Metadata associated with a conversation. /// -public class ConversationMetadata +public sealed class ConversationMetadata { /// /// The title of the conversation. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs index c62116c3..8ae8e559 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents a conversation with its associated messages. /// -public class ConversationWithMessages +public sealed class ConversationWithMessages { /// /// The conversation. diff --git a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs index 78870286..3fdc6748 100644 --- a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs +++ b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs @@ -17,7 +17,7 @@ namespace Goa.Clients.Bedrock; /// High-performance Bedrock service client that implements IBedrockClient using AWS service client infrastructure. /// Provides strongly-typed Bedrock operations with built-in error handling, logging, and AWS authentication. /// -public class BedrockServiceClient : JsonAwsServiceClient, IBedrockClient +public sealed class BedrockServiceClient : JsonAwsServiceClient, IBedrockClient { /// /// Initializes a new instance of the BedrockServiceClient class. diff --git a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs index 23f82172..b7bf196f 100644 --- a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock; /// /// Configuration class for Bedrock Runtime service providing Bedrock-specific settings. /// -public class BedrockServiceClientConfiguration : AwsServiceConfiguration +public sealed class BedrockServiceClientConfiguration : AwsServiceConfiguration { /// /// Initializes a new instance of the BedrockServiceClientConfiguration class. diff --git a/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs b/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs index cdb2403e..1295f1e7 100644 --- a/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs +++ b/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Mcp; /// /// Represents an MCP tool definition that can be converted to Bedrock format. /// -public class McpToolDefinition +public sealed class McpToolDefinition { /// /// The name of the tool. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs b/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs index 1eaa9a45..3ff9d42e 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A cache checkpoint for prompt caching in Bedrock. /// -public class CachePoint +public sealed class CachePoint { /// /// The type of cache point. Currently only "default" is supported. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs index 697148d2..9142af95 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A block of content in a message. Only one type of content can be set per block. /// -public class ContentBlock +public sealed class ContentBlock { /// /// Text content. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs b/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs index dcefe130..e41b2833 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Performance metrics for a conversation request. /// -public class ConverseMetrics +public sealed class ConverseMetrics { /// /// The latency of the request in milliseconds. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs index 9c7fec43..f78f1c80 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A document block in a message. /// -public class DocumentBlock +public sealed class DocumentBlock { /// /// The format of the document (e.g., "pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"). @@ -24,7 +24,7 @@ public class DocumentBlock /// /// The source of a document. /// -public class DocumentSource +public sealed class DocumentSource { /// /// Base64-encoded document bytes. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs index 11764a2d..95c615d4 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for guardrails to apply to the conversation. /// -public class GuardrailConfiguration +public sealed class GuardrailConfiguration { /// /// The identifier of the guardrail to apply. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs index e05b6adb..38bce5dd 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// An image block in a message. /// -public class ImageBlock +public sealed class ImageBlock { /// /// The format of the image (e.g., "png", "jpeg", "gif", "webp"). @@ -19,7 +19,7 @@ public class ImageBlock /// /// The source of an image. /// -public class ImageSource +public sealed class ImageSource { /// /// Base64-encoded image bytes. @@ -35,7 +35,7 @@ public class ImageSource /// /// S3 location reference for content. /// -public class S3Location +public sealed class S3Location { /// /// The S3 URI of the content. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs index bb53a1eb..2c5f1b96 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for model inference parameters. /// -public class InferenceConfiguration +public sealed class InferenceConfiguration { /// /// The maximum number of tokens to generate in the response. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/Message.cs b/src/Clients/Goa.Clients.Bedrock/Models/Message.cs index dc7106bb..b0e0a4c9 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/Message.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/Message.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A message in a conversation with a model. /// -public class Message +public sealed class Message { /// /// The role of the entity sending the message. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs b/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs index a9931720..ac771c0f 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for the output format of a Converse request. /// -public class OutputConfig +public sealed class OutputConfig { /// /// The text format configuration for structured output. @@ -14,7 +14,7 @@ public class OutputConfig /// /// The text output format specification. Set Type to "json_schema" and provide Structure for JSON schema output. /// -public class OutputFormat +public sealed class OutputFormat { /// /// The type of output format. Use "json_schema" for structured output. @@ -30,7 +30,7 @@ public class OutputFormat /// /// The structure definition for a structured output format. This is a union type - only one member should be set. /// -public class OutputFormatStructure +public sealed class OutputFormatStructure { /// /// The JSON schema definition for structured output. @@ -41,7 +41,7 @@ public class OutputFormatStructure /// /// Defines a JSON schema that the model's output must adhere to. /// -public class JsonSchemaDefinition +public sealed class JsonSchemaDefinition { /// /// The JSON schema as a JSON-stringified string. The Converse API requires this as a string, not a JSON object. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs index 381bddb1..441f3ea1 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for performance optimization. /// -public class PerformanceConfiguration +public sealed class PerformanceConfiguration { /// /// The latency mode for model inference. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs b/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs index 322a4899..6d574156 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Token usage information for a conversation request. /// -public class TokenUsage +public sealed class TokenUsage { /// /// The number of tokens in the input. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs b/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs index df936fd7..9d0fe783 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A tool definition that the model can use. /// -public class Tool +public sealed class Tool { /// /// The specification of the tool. @@ -16,7 +16,7 @@ public class Tool /// /// The specification of a tool including its name, description, and input schema. /// -public class ToolSpec +public sealed class ToolSpec { /// /// The name of the tool. @@ -42,7 +42,7 @@ public class ToolSpec /// /// The input schema for a tool. This is a union type - only the Json member should be set. /// -public class ToolInputSchema +public sealed class ToolInputSchema { /// /// The JSON schema for the tool's input parameters. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs index c6f8a696..56b479fb 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for tool use in a conversation. /// -public class ToolConfiguration +public sealed class ToolConfiguration { /// /// The list of tools available to the model. @@ -19,7 +19,7 @@ public class ToolConfiguration /// /// Configuration for how the model should choose which tool to use. /// -public class ToolChoice +public sealed class ToolChoice { /// /// If set, the model will automatically decide whether to use a tool. @@ -40,17 +40,17 @@ public class ToolChoice /// /// Marker class indicating automatic tool choice. /// -public class AutoToolChoice { } +public sealed class AutoToolChoice { } /// /// Marker class indicating any tool can be chosen. /// -public class AnyToolChoice { } +public sealed class AnyToolChoice { } /// /// Configuration to force the model to use a specific tool. /// -public class SpecificToolChoice +public sealed class SpecificToolChoice { /// /// The name of the tool the model must use. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs index 16680146..d9985811 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A tool result block containing the response from a tool invocation. /// -public class ToolResultBlock +public sealed class ToolResultBlock { /// /// The identifier of the tool use request this is a response to. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs index f780a7f4..5dffedf5 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A tool use block indicating the model wants to invoke a tool. /// -public class ToolUseBlock +public sealed class ToolUseBlock { /// /// A unique identifier for this tool use request. diff --git a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs index ec3bf008..0a507582 100644 --- a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs +++ b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Bedrock.Operations.Converse; /// Fluent builder for constructing Bedrock Converse requests. /// /// The identifier of the model to use. -public class ConverseBuilder(string modelId) +public sealed class ConverseBuilder(string modelId) { private readonly ConverseRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs index a4215804..66edd8a1 100644 --- a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs +++ b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Bedrock.Operations.Converse; /// /// Request for the Bedrock Converse API. /// -public class ConverseRequest +public sealed class ConverseRequest { /// /// The identifier of the model to use. @@ -73,7 +73,7 @@ public class ConverseRequest /// /// A system content block for providing context. /// -public class SystemContentBlock +public sealed class SystemContentBlock { /// /// Text content for the system prompt. @@ -91,7 +91,7 @@ public class SystemContentBlock /// /// Request metadata including service tier. /// -public class RequestMetadata +public sealed class RequestMetadata { /// /// The service tier for request processing. diff --git a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs index 9d445b90..367f14f6 100644 --- a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs +++ b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Bedrock.Operations.Converse; /// /// Response from the Bedrock Converse API. /// -public class ConverseResponse +public sealed class ConverseResponse { /// /// The output from the model. @@ -37,7 +37,7 @@ public class ConverseResponse /// /// The output from a Converse API call. /// -public class ConverseOutput +public sealed class ConverseOutput { /// /// The message generated by the model. diff --git a/src/Clients/Goa.Clients.Core/XmlApiError.cs b/src/Clients/Goa.Clients.Core/XmlApiError.cs index f6c94613..3ffd2752 100644 --- a/src/Clients/Goa.Clients.Core/XmlApiError.cs +++ b/src/Clients/Goa.Clients.Core/XmlApiError.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Core; /// /// Represents an XML API error response from AWS services, extending the base ApiError with XML deserialization capabilities. /// -public class XmlApiError : IDeserializeFromXml +public sealed class XmlApiError : IDeserializeFromXml { /// /// Gets or sets the AWS request ID associated with this error. diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs b/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs index 6d98a01f..d39b5ece 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs @@ -5,7 +5,7 @@ /// This attribute is inherited, allowing base classes to define common PK/SK patterns. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] -public class DynamoModelAttribute : Attribute +public sealed class DynamoModelAttribute : Attribute { /// /// The partition key pattern. Supports placeholders like "ENTITY#<PropertyName>" diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index 2db6b367..cb794b31 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -21,7 +21,7 @@ namespace Goa.Clients.Dynamo; /// High-performance DynamoDB service client that implements IDynamoClient using AWS service client infrastructure. /// Provides strongly-typed DynamoDB operations with built-in error handling, logging, and AWS authentication. /// -public class DynamoServiceClient : JsonAwsServiceClient, IDynamoClient +public sealed class DynamoServiceClient : JsonAwsServiceClient, IDynamoClient { /// /// Initializes a new instance of the DynamoServiceClient class. diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs index 245d69ce..14853898 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo; /// /// Configuration class for DynamoDB service providing DynamoDB-specific settings. /// -public class DynamoServiceClientConfiguration : AwsServiceConfiguration +public sealed class DynamoServiceClientConfiguration : AwsServiceConfiguration { /// /// Initializes a new instance of the DynamoServiceClientConfiguration class. diff --git a/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs b/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs index df5f8255..4c55d244 100644 --- a/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs +++ b/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Exceptions; /// Exception thrown when an auto-pagination operation encounters an error during a page request. /// This prevents silent truncation of results in methods like QueryAllAsync, ScanAllAsync, and BatchGetAllAsync. /// -public class DynamoPaginationException : Exception +public sealed class DynamoPaginationException : Exception { /// /// Gets the underlying error that caused the pagination failure. diff --git a/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs b/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs index 5362fb2a..4a5120fe 100644 --- a/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs +++ b/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Dynamo.Exceptions; /// /// Exception thrown when a required DynamoDB attribute is missing or null. /// -public class MissingAttributeException : Exception +public sealed class MissingAttributeException : Exception { /// /// Gets the name of the missing attribute. diff --git a/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs b/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs index 393821f7..2d7d43fe 100644 --- a/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs @@ -11,6 +11,6 @@ namespace Goa.Clients.Dynamo; /// in your project file. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public class ExtensionAttribute : Attribute +public sealed class ExtensionAttribute : Attribute { } diff --git a/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs b/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs index 9d58ea37..fe9e001d 100644 --- a/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs @@ -5,7 +5,7 @@ /// This attribute is inherited, allowing base classes to define common GSI patterns. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public class GlobalSecondaryIndexAttribute : Attribute +public sealed class GlobalSecondaryIndexAttribute : Attribute { /// /// The name of the Global Secondary Index in DynamoDB. diff --git a/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs b/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs index d5bfc056..fbedefad 100644 --- a/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo; /// By default, ignores the property in both directions (reading and writing). /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class IgnoreAttribute : Attribute +public sealed class IgnoreAttribute : Attribute { /// /// The direction in which to ignore the property. Defaults to Always. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs b/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs index ec82cc43..017f9d42 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Detailed capacity information for DynamoDB operations. /// -public class CapacityDetail +public sealed class CapacityDetail { /// /// The number of read capacity units consumed by the operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs index 5deb7288..6a3ef92c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Represents consumed capacity information for DynamoDB operations. /// -public class ConsumedCapacity +public sealed class ConsumedCapacity { /// /// The name of the table that was affected by the operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs b/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs index 71a9ea12..ea22c5e8 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Represents a DynamoDB record with strongly-typed attribute values. /// -public class DynamoRecord : Dictionary +public sealed class DynamoRecord : Dictionary { /// /// Initializes a new, empty instance of the class. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs b/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs index bd3eb9c4..19d3f1a4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Information about item collections, if any, that were affected by the operation. /// -public class ItemCollectionMetrics +public sealed class ItemCollectionMetrics { /// /// The partition key value of the item collection. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs index 71356ed0..c8a12346 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for constructing DynamoDB BatchGetItem requests with a user-friendly API. /// -public class BatchGetItemBuilder +public sealed class BatchGetItemBuilder { private readonly BatchGetItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs index 20e38bf6..e33c469a 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Request for batch getting items from DynamoDB. /// -public class BatchGetItemRequest +public sealed class BatchGetItemRequest { /// /// A map of one or more table names and, for each table, a map that describes one or more items to retrieve from that table. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs index 61232af3..f6ae1aac 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Response for BatchGetItem operations. /// -public class BatchGetItemResponse +public sealed class BatchGetItemResponse { /// /// A map of table name to a list of items. Each item is represented as a set of attributes. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs index 6c1c8876..7b783e66 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Individual table request within a BatchGetItem operation. /// -public class BatchGetRequestItem +public sealed class BatchGetRequestItem { /// /// The primary keys of the items to retrieve. For each key, DynamoDB performs a GetItem operation and returns the entire item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs index 64f66bcc..4d544fe1 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for configuring table-specific requests within a BatchGetItem operation. /// -public class BatchGetTableBuilder +public sealed class BatchGetTableBuilder { private readonly BatchGetRequestItem _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs index c3f5c117..1689fc90 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for constructing DynamoDB BatchWriteItem requests with a user-friendly API. /// -public class BatchWriteItemBuilder +public sealed class BatchWriteItemBuilder { private readonly BatchWriteItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs index 97b26738..2063ac63 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Request for batch writing items to DynamoDB. /// -public class BatchWriteItemRequest +public sealed class BatchWriteItemRequest { /// /// A map of one or more table names and, for each table, a list of operations to be performed. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs index af643513..aa1169c7 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Response for BatchWriteItem operations. /// -public class BatchWriteItemResponse +public sealed class BatchWriteItemResponse { /// /// A map of tables and requests against those tables that were not processed. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs index 12522fa6..a0896a80 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Individual write request within a BatchWriteItem operation. /// -public class BatchWriteRequestItem +public sealed class BatchWriteRequestItem { /// /// A request to perform a PutItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs index 8a69683b..fdd8044b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Result wrapper for BatchWrite operations. /// -public class BatchWriteResult +public sealed class BatchWriteResult { /// /// Put items that were not processed during the BatchWriteItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs index c91c91a6..19a891ea 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for configuring table-specific requests within a BatchWriteItem operation. /// -public class BatchWriteTableBuilder +public sealed class BatchWriteTableBuilder { private readonly List _requests = new(); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs index dcaf8f65..7625bfb3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Delete request within a batch write operation. /// -public class DeleteRequest +public sealed class DeleteRequest { /// /// The primary key of the item to be deleted. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs index c0d766c8..b37c9021 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Put request within a batch write operation. /// -public class PutRequest +public sealed class PutRequest { /// /// A map of attribute names to AttributeValue objects representing the item to be put. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs index 8b304601..0a104461 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations; /// /// Represents a condition expression for DynamoDB operations with associated attribute names and values. /// -public class Condition +public sealed class Condition { /// /// Gets the condition expression string. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs index 184d8f45..35eba6d3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Dynamo.Operations.DeleteItem; /// Fluent builder for constructing DynamoDB DeleteItem requests with a user-friendly API. /// /// The name of the table to delete the item from. -public class DeleteItemBuilder(string tableName) +public sealed class DeleteItemBuilder(string tableName) { private readonly DeleteItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs index d3b30b93..b7b14f84 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.DeleteItem; /// /// Request for deleting an item from DynamoDB. /// -public class DeleteItemRequest +public sealed class DeleteItemRequest { /// /// The name of the table from which to delete the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs index 44575d12..f7e3abe7 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.DeleteItem; /// /// Response for DeleteItem operations. /// -public class DeleteItemResponse +public sealed class DeleteItemResponse { /// /// A map of attribute names to AttributeValue objects representing the item as it appeared before it was deleted. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs index f6a50a47..54a6d79c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.GetItem; /// Fluent builder for constructing DynamoDB GetItem requests with a user-friendly API. /// /// The name of the table to get the item from. -public class GetItemBuilder(string tableName) +public sealed class GetItemBuilder(string tableName) { private readonly GetItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs index d76357cd..acba3f41 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.GetItem; /// /// Request for getting an item from DynamoDB. /// -public class GetItemRequest +public sealed class GetItemRequest { /// /// The name of the table containing the requested item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs index 23ebfa8c..ffa40028 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.GetItem; /// /// Response for GetItem operations. /// -public class GetItemResponse +public sealed class GetItemResponse { /// /// A map of attribute names to AttributeValue objects, as specified by ProjectionExpression. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs index 4e2901d5..582de370 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; /// Fluent builder for constructing DynamoDB PutItem requests with a user-friendly API. /// /// The name of the table to put the item into. -public class PutItemBuilder(string tableName) +public sealed class PutItemBuilder(string tableName) { private readonly PutItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs index 0412ff60..88bd3356 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; /// /// Request for putting an item into DynamoDB. /// -public class PutItemRequest +public sealed class PutItemRequest { /// /// The name of the table to contain the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs index 264f2e06..38cf2b82 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; /// /// Response for PutItem operations. /// -public class PutItemResponse +public sealed class PutItemResponse { /// /// The attribute values as they appeared before the PutItem operation, diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs index 01d15151..d9f5f4fc 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Query; /// /// Request for querying items from DynamoDB. /// -public class QueryRequest +public sealed class QueryRequest { /// /// The name of the table containing the requested items. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs index d6606f10..d6420f1b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Query; /// /// Response for Query operations with pagination support. /// -public class QueryResponse +public sealed class QueryResponse { /// /// An array of item attributes that match the query criteria. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs index c3695f8b..6c73e0e4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Query; /// /// Result wrapper for Query operations with pagination support. /// -public class QueryResult +public sealed class QueryResult { /// /// The items returned by the Query operation. @@ -51,7 +51,7 @@ public class QueryResult /// Typed result wrapper for Query operations with direct deserialization support. /// /// The type of the deserialized items. -public class QueryResult +public sealed class QueryResult { /// [JsonPropertyName("Items")] diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs index 9231ffde..e0f1596f 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// Fluent builder for constructing DynamoDB Scan requests with a user-friendly API. /// /// The name of the table to scan. -public class ScanBuilder(string tableName) +public sealed class ScanBuilder(string tableName) { private readonly ScanRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs index 8213917d..279e994e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// /// Request for scanning items from DynamoDB. /// -public class ScanRequest +public sealed class ScanRequest { /// /// The name of the table containing the requested items. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs index b9fb807e..7f7faba4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// /// Response for Scan operations with pagination support. /// -public class ScanResponse +public sealed class ScanResponse { /// /// An array of item attributes that match the scan criteria. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs index a47eb3ea..fbdfc418 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// /// Result wrapper for Scan operations with pagination support. /// -public class ScanResult +public sealed class ScanResult { /// /// The items returned by the Scan operation. @@ -51,7 +51,7 @@ public class ScanResult /// Typed result wrapper for Scan operations with direct deserialization support. /// /// The type of the deserialized items. -public class ScanResult +public sealed class ScanResult { /// [JsonPropertyName("Items")] diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs index a7e46638..31ac4371 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Condition check operation in a transaction. /// -public class TransactConditionCheckItem +public sealed class TransactConditionCheckItem { /// /// The name of the table containing the requested item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs index b19bc059..729c122d 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Delete operation in a transaction. /// -public class TransactDeleteItem +public sealed class TransactDeleteItem { /// /// The name of the table from which to delete the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs index c8ab5981..8f30c926 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Fluent builder for constructing DynamoDB TransactGet requests with a user-friendly API. /// -public class TransactGetBuilder +public sealed class TransactGetBuilder { private readonly TransactGetRequest _request = new(); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs index e41a7057..5cad71d3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Individual transact get item. /// -public class TransactGetItem +public sealed class TransactGetItem { /// /// Contains the primary key that identifies the item to get, together with the name of the table that contains the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs index 3a37f75c..2bc0dec9 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Get request within a transact get operation. /// -public class TransactGetItemRequest +public sealed class TransactGetItemRequest { /// /// The name of the table containing the requested item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs index 9ca8c401..1fa71f78 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Response for TransactGetItem operations. /// -public class TransactGetItemResponse +public sealed class TransactGetItemResponse { /// /// An ordered array of up to 100 response items, each of which corresponds to the TransactGetItem request in the same position. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs index 692cf43e..d83c308c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Request for transactional get operations. /// -public class TransactGetRequest +public sealed class TransactGetRequest { /// /// An ordered array of up to 100 TransactGetItem objects, each of which contains a Get operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs index c7960c32..96d911d0 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Result item from TransactGetItem operation. /// -public class TransactGetResult +public sealed class TransactGetResult { /// /// Map of attribute names to values for the requested item, or null if the item was not found. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs index 5fd27526..4f7aa1d1 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Put operation in a transaction. /// -public class TransactPutItem +public sealed class TransactPutItem { /// /// The name of the table to contain the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs index aa9f671f..0f3a645c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Update operation in a transaction. /// -public class TransactUpdateItem +public sealed class TransactUpdateItem { /// /// The name of the table containing the item to be updated. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs index 071fc177..34d92147 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Fluent builder for constructing DynamoDB TransactWrite requests with a user-friendly API. /// -public class TransactWriteBuilder +public sealed class TransactWriteBuilder { private readonly TransactWriteRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs index 20ec5540..7abae7cf 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Individual transact write item. /// -public class TransactWriteItem +public sealed class TransactWriteItem { /// /// A request to perform a PutItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs index 08bf2f0b..d0ba3ce9 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Response for TransactWriteItem operations. /// -public class TransactWriteItemResponse +public sealed class TransactWriteItemResponse { /// /// The capacity units consumed by the entire TransactWriteItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs index 85f84da6..928e6c63 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Represents a transaction write operation. /// -public class TransactWriteOperation +public sealed class TransactWriteOperation { /// /// The type of operation to perform (Put, Update, Delete, or ConditionCheck). diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs index 6a1cec6e..be91d7cd 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Request for transactional write operations. /// -public class TransactWriteRequest +public sealed class TransactWriteRequest { /// /// An ordered array of up to 100 TransactWriteItem objects, each of which contains a ConditionCheck, Put, Update, or Delete operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs index 7094d476..9d08dff9 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.UpdateItem; /// /// Request for updating an item in DynamoDB. /// -public class UpdateItemRequest +public sealed class UpdateItemRequest { /// /// The name of the table containing the item to update. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs index 330778fc..f16a0d42 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.UpdateItem; /// /// Response for UpdateItem operations. /// -public class UpdateItemResponse +public sealed class UpdateItemResponse { /// /// A map of attribute names to AttributeValue objects representing the item as it appeared before it was updated. diff --git a/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs b/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs index 2df241fe..7a51ea56 100644 --- a/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo; /// By default, property names are used as-is for DynamoDB attribute names. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class SerializedNameAttribute : Attribute +public sealed class SerializedNameAttribute : Attribute { /// /// Initializes a new instance of the SerializedNameAttribute class. diff --git a/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs b/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs index 37a0268e..a20f0701 100644 --- a/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs @@ -6,7 +6,7 @@ /// When used in composite PK/SK patterns, they are formatted as strings. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class UnixTimestampAttribute : Attribute +public sealed class UnixTimestampAttribute : Attribute { /// /// The format for the unix timestamp. Defaults to Seconds (AWS TTL compatible). From 51f120f6d55c78bcf35685361db2f5bacb6608de Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 12:44:00 +0000 Subject: [PATCH 02/34] perf: Zero-allocation response logging in AwsServiceClient.SendAsync Add LogScope2 struct for the fixed StatusCode/ReasonPhrase fields, replacing the Dictionary allocation on every AWS call. Variable x-amz headers use a lazy Dictionary with capacity hint of 4. --- .../Goa.Clients.Core/AwsServiceClient.cs | 21 ++++++---- .../Goa.Clients.Core/Logging/LogScope.cs | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index c60d3953..ca9d193d 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -85,18 +85,23 @@ protected async Task SendAsync(HttpRequestMessage request, var start = Stopwatch.GetTimestamp(); var response = await client.SendAsync(request, cancellationToken); - // Ensure that we log the applicable response headers and status code - var context = new Dictionary - { - ["StatusCode"] = ((int)response.StatusCode).ToString(), - ["ReasonPhrase"] = response.ReasonPhrase ?? response.StatusCode.ToString() - }; + // Log fixed response fields with zero-allocation scope + using var responseLogContext = Logger.BeginScope(new LogScope2( + new("StatusCode", ((int)response.StatusCode).ToString()), + new("ReasonPhrase", response.ReasonPhrase ?? response.StatusCode.ToString()) + )); + + // Log x-amz response headers separately with capacity hint + Dictionary? amzHeaders = null; foreach (var header in response.Headers) { if (header.Key.StartsWith("x-amz", StringComparison.OrdinalIgnoreCase)) - context[header.Key] = string.Join(", ", header.Value); + { + amzHeaders ??= new Dictionary(4); + amzHeaders[header.Key] = string.Join(", ", header.Value); + } } - using var responseLogContext = Logger.BeginScope(context); + using var amzLogContext = amzHeaders is not null ? Logger.BeginScope(amzHeaders) : null; Logger.RequestComplete(Configuration.LogLevel, Stopwatch.GetElapsedTime(start).TotalMilliseconds); diff --git a/src/Clients/Goa.Clients.Core/Logging/LogScope.cs b/src/Clients/Goa.Clients.Core/Logging/LogScope.cs index 9362351e..6959f738 100644 --- a/src/Clients/Goa.Clients.Core/Logging/LogScope.cs +++ b/src/Clients/Goa.Clients.Core/Logging/LogScope.cs @@ -48,6 +48,46 @@ public void Dispose() { } } } +/// +/// Zero-allocation log scope with 2 entries. Implements IReadOnlyList so +/// Microsoft.Extensions.Logging can enumerate properties without a Dictionary allocation. +/// +internal readonly struct LogScope2 : IReadOnlyList> +{ + private readonly KeyValuePair _0, _1; + + public LogScope2(KeyValuePair p0, KeyValuePair p1) + { + _0 = p0; _1 = p1; + } + + public int Count => 2; + + public KeyValuePair this[int index] => index switch + { + 0 => _0, 1 => _1, + _ => throw new IndexOutOfRangeException() + }; + + public Enumerator GetEnumerator() => new(this); + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public struct Enumerator : IEnumerator> + { + private readonly LogScope2 _scope; + private int _index; + + internal Enumerator(LogScope2 scope) { _scope = scope; _index = -1; } + + public KeyValuePair Current => _scope[_index]; + object IEnumerator.Current => Current; + public bool MoveNext() => ++_index < 2; + public void Reset() => _index = -1; + public void Dispose() { } + } +} + /// /// Zero-allocation log scope with up to 4 entries. Implements IReadOnlyList so /// Microsoft.Extensions.Logging can enumerate properties without a Dictionary allocation. From 7373dd0f8d2c800ee46a186c711662d32c469442 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 12:47:12 +0000 Subject: [PATCH 03/34] perf: Add collection capacity hints to reduce resizing allocations Add capacity arguments to Dictionary/List constructors where the size is known or predictable: SNS query params, SQS batch chunking, DynamoDB conversation store items, and content block serialization. --- .../DynamoConversationStore.cs | 34 +++++++++---------- .../Internal/ContentBlockSerializer.cs | 10 +++--- .../Internal/XmlTagParser.cs | 2 +- .../Goa.Clients.Sns/SnsServiceClient.cs | 2 +- src/Clients/Goa.Clients.Sqs/SqsExtensions.cs | 4 ++- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs index 35a739a8..32256bed 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs @@ -63,7 +63,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto var conversationId = Guid.CreateVersion7().ToString("N"); var now = DateTimeOffset.UtcNow; - var item = new Dictionary + var item = new Dictionary(8) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue, @@ -111,7 +111,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto var request = new GetItemRequest { TableName = _configuration.TableName, - Key = new Dictionary + Key = new Dictionary(2) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue @@ -145,7 +145,7 @@ public async Task> GetConversationWithMessages { TableName = _configuration.TableName, KeyConditionExpression = $"{_configuration.PartitionKeyName} = :pk AND begins_with({_configuration.SortKeyName}, :skPrefix)", - ExpressionAttributeValues = new Dictionary + ExpressionAttributeValues = new Dictionary(2) { [":pk"] = _configuration.ConversationPkFormat(conversationId), [":skPrefix"] = _configuration.MessageSkPrefix @@ -255,7 +255,7 @@ private async Task>> AddMessagesInter var sequenceNumber = startSequence + i; var messageId = Guid.NewGuid().ToString("N"); - var messageItem = new Dictionary + var messageItem = new Dictionary(12) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.MessageSkFormat(conversationId, sequenceNumber), @@ -292,7 +292,7 @@ private async Task>> AddMessagesInter // Add extracted tags if present if (extractedTags is not null && extractedTags.Count > 0) { - var tagsMap = new Dictionary(); + var tagsMap = new Dictionary(extractedTags.Count); foreach (var kvp in extractedTags) { var tagValues = new List(); @@ -335,13 +335,13 @@ private async Task>> AddMessagesInter } // Build update expression for conversation - var updateParts = new List + var updateParts = new List(5) { $"{MessageCountAttribute} = {MessageCountAttribute} + :msgCount", $"{UpdatedAtAttribute} = :updatedAt" }; - var updateExpressionValues = new Dictionary + var updateExpressionValues = new Dictionary(6) { [":msgCount"] = AttributeValue.Number(messageList.Count.ToString()), [":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) @@ -363,7 +363,7 @@ private async Task>> AddMessagesInter Update = new TransactUpdateItem { TableName = _configuration.TableName, - Key = new Dictionary + Key = new Dictionary(2) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue @@ -393,12 +393,12 @@ private async Task>> AddMessagesInter { var now = DateTimeOffset.UtcNow; - var updateParts = new List { $"{UpdatedAtAttribute} = :updatedAt" }; - var expressionAttributeValues = new Dictionary + var updateParts = new List(4) { $"{UpdatedAtAttribute} = :updatedAt" }; + var expressionAttributeValues = new Dictionary(4) { [":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) }; - var expressionAttributeNames = new Dictionary(); + var expressionAttributeNames = new Dictionary(1); if (metadata.Title is not null) { @@ -422,7 +422,7 @@ private async Task>> AddMessagesInter { updateParts.Add($"#customData = :customData"); expressionAttributeNames["#customData"] = CustomDataAttribute; - var customDataMap = new Dictionary(); + var customDataMap = new Dictionary(metadata.CustomData.Count); foreach (var kvp in metadata.CustomData) { customDataMap[kvp.Key] = kvp.Value; @@ -433,7 +433,7 @@ private async Task>> AddMessagesInter var updateRequest = new UpdateItemRequest { TableName = _configuration.TableName, - Key = new Dictionary + Key = new Dictionary(2) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue @@ -469,7 +469,7 @@ public async Task> DeleteConversationAsync(string conversationI { TableName = _configuration.TableName, KeyConditionExpression = $"{_configuration.PartitionKeyName} = :pk", - ExpressionAttributeValues = new Dictionary + ExpressionAttributeValues = new Dictionary(1) { [":pk"] = pk } @@ -486,7 +486,7 @@ public async Task> DeleteConversationAsync(string conversationI foreach (var item in queryResult.Value.Items) { - allItems.Add(new Dictionary + allItems.Add(new Dictionary(2) { [_configuration.PartitionKeyName] = item[_configuration.PartitionKeyName]!.Value, [_configuration.SortKeyName] = item[_configuration.SortKeyName]!.Value @@ -539,7 +539,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary< if (metadata.CustomData.Count > 0) { - var customDataMap = new Dictionary(); + var customDataMap = new Dictionary(metadata.CustomData.Count); foreach (var kvp in metadata.CustomData) { customDataMap[kvp.Key] = kvp.Value; @@ -665,7 +665,7 @@ private ErrorOr DeserializeMessage(DynamoRecord record) IReadOnlyDictionary>? extractedTags = null; if (record.TryGetMap(ExtractedTagsAttribute, out var tagsMap) && tagsMap is not null) { - var tags = new Dictionary>(); + var tags = new Dictionary>(tagsMap.Count); foreach (var kvp in tagsMap) { if (kvp.Value.L is not null) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs index 95a772d9..0c9d5150 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs @@ -26,7 +26,7 @@ public static ErrorOr Serialize(ContentBlock block) { if (block.Text is not null) { - return AttributeValue.FromMap(new Dictionary + return AttributeValue.FromMap(new Dictionary(2) { [TypeAttribute] = TextType, ["text"] = block.Text @@ -39,7 +39,7 @@ public static ErrorOr Serialize(ContentBlock block) if (validation.IsError) return validation.Errors; - var imageMap = new Dictionary + var imageMap = new Dictionary(4) { [TypeAttribute] = ImageType, ["format"] = block.Image.Format @@ -61,7 +61,7 @@ public static ErrorOr Serialize(ContentBlock block) if (validation.IsError) return validation.Errors; - var docMap = new Dictionary + var docMap = new Dictionary(5) { [TypeAttribute] = DocumentType, ["format"] = block.Document.Format, @@ -80,7 +80,7 @@ public static ErrorOr Serialize(ContentBlock block) if (block.ToolUse is not null) { - var toolUseMap = new Dictionary + var toolUseMap = new Dictionary(4) { [TypeAttribute] = ToolUseType, ["toolUseId"] = block.ToolUse.ToolUseId, @@ -102,7 +102,7 @@ public static ErrorOr Serialize(ContentBlock block) contentList.Add(serialized.Value); } - var toolResultMap = new Dictionary + var toolResultMap = new Dictionary(4) { [TypeAttribute] = ToolResultType, ["toolUseId"] = block.ToolResult.ToolUseId, diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs index 94e5e198..226c90e3 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs @@ -43,7 +43,7 @@ public static (string CleanedText, IReadOnlyDictionary>(); + var result = new Dictionary>(tags.Count); foreach (var kvp in tags) { result[kvp.Key] = kvp.Value; diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index 62d3be6e..3c65a60f 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -70,7 +70,7 @@ public async Task> PublishAsync(PublishRequest request, /// private static string SerializeToQueryParameters(PublishRequest request) { - var parameters = new List + var parameters = new List(12) { "Action=Publish", "Version=2010-03-31" diff --git a/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs b/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs index dc19c88e..b02be14a 100644 --- a/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs +++ b/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs @@ -127,7 +127,9 @@ private static async Task> SendBatch } // Chunk into batches of 10 - var chunks = entries.Chunk(MaxBatchSize).ToList(); + var chunkCount = (entries.Count + MaxBatchSize - 1) / MaxBatchSize; + var chunks = new List(chunkCount); + chunks.AddRange(entries.Chunk(MaxBatchSize)); // Create batch requests var batchTasks = chunks.Select((chunk, batchIndex) => From 1f213c6a0a9a7462e53560e669866e6f7adccfeb Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 12:48:39 +0000 Subject: [PATCH 04/34] refactor: Consolidate ProcessAwsErrorHeaders into base AwsServiceClient Move the identical ProcessAwsErrorHeaders method from JsonAwsServiceClient, XmlAwsServiceClient, and SnsServiceClient into the shared AwsServiceClient base class to eliminate duplication. --- .../Goa.Clients.Core/AwsServiceClient.cs | 30 ++++++++++++++++ .../Goa.Clients.Core/JsonAwsServiceClient.cs | 34 ------------------- .../Goa.Clients.Core/XmlAwsServiceClient.cs | 30 ---------------- .../Goa.Clients.Sns/SnsServiceClient.cs | 30 ---------------- 4 files changed, 30 insertions(+), 94 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index ca9d193d..a1ec5905 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -114,6 +114,36 @@ protected async Task SendAsync(HttpRequestMessage request, } } + /// + /// Applies AWS-specific error header processing to the error object. + /// + protected static ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) + { + if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) + { + error = error with { Message = string.Join(", ", messages) }; + } + + if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) + { + error = error with { Type = string.Join(", ", types) }; + } + + var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; + if (infoSeparator > 0) + { + error = error with { Type = error.Type![..infoSeparator] }; + } + + var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; + if (typeSeparator > 0) + { + error = error with { Type = error.Type![(typeSeparator + 1)..] }; + } + + return error; + } + /// /// Creates an HTTP request message with the specified content. /// diff --git a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs index d7751b8e..c954b5f1 100644 --- a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs @@ -143,40 +143,6 @@ protected async Task> ProcessJsonResponseAsync /// The resolved JSON type info. protected abstract JsonTypeInfo ResolveJsonTypeInfo(); - /// - /// Applies AWS-specific error header processing to the error object. - /// - protected ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) - { - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L79 - if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) - { - error = error with { Message = string.Join(", ", messages) }; - } - - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L60 - if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) - { - error = error with { Type = string.Join(", ", types) }; - } - - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L68 - var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; - if (infoSeparator > 0) - { - error = error with { Type = error.Type![..infoSeparator] }; - } - - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L95 - var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; - if (typeSeparator > 0) - { - error = error with { Type = error.Type![(typeSeparator + 1)..] }; - } - - return error; - } - /// /// Deserializes JSON error response content to an ApiError. /// diff --git a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs index 6ed782f6..40e4f36a 100644 --- a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs @@ -120,36 +120,6 @@ private async Task> ProcessXmlResponseAsync(Ht return new ApiResponse(result, headers); } - /// - /// Applies AWS-specific error header processing to the error object. - /// - private ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) - { - if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) - { - error = error with { Message = string.Join(", ", messages) }; - } - - if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) - { - error = error with { Type = string.Join(", ", types) }; - } - - var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; - if (infoSeparator > 0) - { - error = error with { Type = error.Type![..infoSeparator] }; - } - - var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; - if (typeSeparator > 0) - { - error = error with { Type = error.Type![(typeSeparator + 1)..] }; - } - - return error; - } - /// /// Serializes a request object to UTF-8 bytes using the ISerializeToXml interface. /// diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index 3c65a60f..aeda16ae 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -173,36 +173,6 @@ private async Task> ProcessSnsResponseAsync(Ht return new ApiResponse(result, headers); } - /// - /// Applies AWS-specific error header processing to the error object. - /// - private ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) - { - if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) - { - error = error with { Message = string.Join(", ", messages) }; - } - - if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) - { - error = error with { Type = string.Join(", ", types) }; - } - - var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; - if (infoSeparator > 0) - { - error = error with { Type = error.Type![..infoSeparator] }; - } - - var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; - if (typeSeparator > 0) - { - error = error with { Type = error.Type![(typeSeparator + 1)..] }; - } - - return error; - } - /// /// Deserializes SNS XML error response content to an ApiError. /// From 075516605509b426574f576b4bcbfdc7eaf65238 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 12:51:08 +0000 Subject: [PATCH 05/34] perf: Port pooled response reading to XML and SNS clients Move ReadResponseBytesAsync and PooledBuffer from JsonAwsServiceClient to the base AwsServiceClient class. Update XmlAwsServiceClient and SnsServiceClient to use pooled byte buffers instead of ReadAsStringAsync, avoiding intermediate StreamReader allocations. --- .../Goa.Clients.Core/AwsServiceClient.cs | 120 ++++++++++++++++++ .../Goa.Clients.Core/JsonAwsServiceClient.cs | 120 ------------------ .../Goa.Clients.Core/XmlAwsServiceClient.cs | 12 +- .../Goa.Clients.Sns/SnsServiceClient.cs | 12 +- 4 files changed, 134 insertions(+), 130 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index a1ec5905..bc3dde0e 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -2,6 +2,7 @@ using Goa.Clients.Core.Http; using Goa.Clients.Core.Logging; using Microsoft.Extensions.Logging; +using System.Buffers; using System.Diagnostics; using System.Net.Http.Headers; @@ -244,4 +245,123 @@ protected HttpRequestMessage CreateRequestMessage(HttpMethod method, string requ return requestMessage; } + + /// + /// Maximum allowed response size in bytes (4 MB). + /// + protected const int MaxResponseSize = 4 * 1024 * 1024; + + /// + /// Reads the HTTP response body into a pooled byte buffer for zero-copy deserialization. + /// + /// The HTTP response message. + /// Token to cancel the operation. + /// A pooled buffer containing the response bytes. Must be disposed after use. + protected static async Task ReadResponseBytesAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var headerLength = response.Content.Headers.ContentLength; + if (headerLength is 0) + return default; + + if (headerLength is not null) + { + if (headerLength < 0 || headerLength > MaxResponseSize) + throw new InvalidOperationException($"Response Content-Length {headerLength} exceeds maximum {MaxResponseSize} bytes."); + + var contentLength = (int)headerLength; + var knownBuffer = ArrayPool.Shared.Rent(contentLength); + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var totalRead = 0; + while (totalRead < contentLength) + { + var read = await stream.ReadAsync(knownBuffer.AsMemory(totalRead, contentLength - totalRead), cancellationToken); + if (read == 0) break; + totalRead += read; + } + return new PooledBuffer(knownBuffer, totalRead); + } + catch + { + ArrayPool.Shared.Return(knownBuffer); + throw; + } + } + + const int initialSize = 4096; + var buffer = ArrayPool.Shared.Rent(initialSize); + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var totalRead = 0; + while (true) + { + if (totalRead == buffer.Length) + { + var newSize = buffer.Length * 2; + if (newSize > MaxResponseSize) + newSize = MaxResponseSize + 1; // allow one more read to detect overflow + + var newBuffer = ArrayPool.Shared.Rent(newSize); + buffer.AsSpan(0, totalRead).CopyTo(newBuffer); + ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } + + var read = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken); + if (read == 0) break; + totalRead += read; + + if (totalRead > MaxResponseSize) + throw new InvalidOperationException($"Response size exceeds maximum {MaxResponseSize} bytes."); + } + + if (totalRead == 0) + { + ArrayPool.Shared.Return(buffer); + return default; + } + + return new PooledBuffer(buffer, totalRead); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + /// + /// A disposable buffer backed by ArrayPool for zero-allocation response reading. + /// + protected struct PooledBuffer : IDisposable + { + private byte[]? _buffer; + + /// The number of valid bytes in the buffer. + public readonly int Length; + + /// Creates a new PooledBuffer wrapping the given rented array. + public PooledBuffer(byte[] buffer, int length) + { + _buffer = buffer; + Length = length; + } + + /// Gets a read-only span over the valid portion of the buffer. + public readonly ReadOnlySpan Span => _buffer is null ? default : _buffer.AsSpan(0, Length); + + /// Returns the rented buffer to the shared ArrayPool. + public void Dispose() + { + var buf = _buffer; + if (buf is not null) + { + _buffer = null; + ArrayPool.Shared.Return(buf); + } + } + } } diff --git a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs index c954b5f1..cca8c6c3 100644 --- a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs @@ -2,7 +2,6 @@ using Goa.Clients.Core.Http; using Goa.Clients.Core.Logging; using Microsoft.Extensions.Logging; -using System.Buffers; using System.Net.Http.Headers; using System.Text; @@ -156,123 +155,4 @@ private static bool IsJsonSerialized(string input) var trimmed = input.TrimStart(); return trimmed.StartsWith('{') || trimmed.StartsWith('['); } - - /// - /// Maximum allowed response size in bytes (4 MB). - /// - protected const int MaxResponseSize = 4 * 1024 * 1024; - - /// - /// Reads the HTTP response body into a pooled byte buffer for zero-copy deserialization. - /// - /// The HTTP response message. - /// Token to cancel the operation. - /// A pooled buffer containing the response bytes. Must be disposed after use. - protected static async Task ReadResponseBytesAsync( - HttpResponseMessage response, CancellationToken cancellationToken) - { - var headerLength = response.Content.Headers.ContentLength; - if (headerLength is 0) - return default; - - if (headerLength is not null) - { - if (headerLength < 0 || headerLength > MaxResponseSize) - throw new InvalidOperationException($"Response Content-Length {headerLength} exceeds maximum {MaxResponseSize} bytes."); - - var contentLength = (int)headerLength; - var knownBuffer = System.Buffers.ArrayPool.Shared.Rent(contentLength); - try - { - var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var totalRead = 0; - while (totalRead < contentLength) - { - var read = await stream.ReadAsync(knownBuffer.AsMemory(totalRead, contentLength - totalRead), cancellationToken); - if (read == 0) break; - totalRead += read; - } - return new PooledBuffer(knownBuffer, totalRead); - } - catch - { - System.Buffers.ArrayPool.Shared.Return(knownBuffer); - throw; - } - } - - const int initialSize = 4096; - var buffer = System.Buffers.ArrayPool.Shared.Rent(initialSize); - try - { - var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var totalRead = 0; - while (true) - { - if (totalRead == buffer.Length) - { - var newSize = buffer.Length * 2; - if (newSize > MaxResponseSize) - newSize = MaxResponseSize + 1; // allow one more read to detect overflow - - var newBuffer = System.Buffers.ArrayPool.Shared.Rent(newSize); - buffer.AsSpan(0, totalRead).CopyTo(newBuffer); - System.Buffers.ArrayPool.Shared.Return(buffer); - buffer = newBuffer; - } - - var read = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken); - if (read == 0) break; - totalRead += read; - - if (totalRead > MaxResponseSize) - throw new InvalidOperationException($"Response size exceeds maximum {MaxResponseSize} bytes."); - } - - if (totalRead == 0) - { - System.Buffers.ArrayPool.Shared.Return(buffer); - return default; - } - - return new PooledBuffer(buffer, totalRead); - } - catch - { - System.Buffers.ArrayPool.Shared.Return(buffer); - throw; - } - } - - /// - /// A disposable buffer backed by ArrayPool for zero-allocation response reading. - /// - protected struct PooledBuffer : IDisposable - { - private byte[]? _buffer; - - /// The number of valid bytes in the buffer. - public readonly int Length; - - /// Creates a new PooledBuffer wrapping the given rented array. - public PooledBuffer(byte[] buffer, int length) - { - _buffer = buffer; - Length = length; - } - - /// Gets a read-only span over the valid portion of the buffer. - public readonly ReadOnlySpan Span => _buffer is null ? default : _buffer.AsSpan(0, Length); - - /// Returns the rented buffer to the shared ArrayPool. - public void Dispose() - { - var buf = _buffer; - if (buf is not null) - { - _buffer = null; - System.Buffers.ArrayPool.Shared.Return(buf); - } - } - } } diff --git a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs index 40e4f36a..b3c691b7 100644 --- a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs @@ -60,26 +60,27 @@ protected async Task> SendAsync(Http using var requestMessage = CreateRequestMessage(method, requestUri + $"?Action={UrlEncoder.Default.Encode(target)}", content, new MediaTypeHeaderValue("application/xml"), headers); using var response = await SendAsync(requestMessage, target, cancellationToken); - return await ProcessXmlResponseAsync(response); + return await ProcessXmlResponseAsync(response, cancellationToken); } /// /// Processes an HTTP response and converts it to an API response with XML deserialization. /// - private async Task> ProcessXmlResponseAsync(HttpResponseMessage response) + private async Task> ProcessXmlResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where TResponse : class, IDeserializeFromXml, new() { var headers = ResponseHeaders.FromHttpResponse(response.Headers); if (!response.IsSuccessStatusCode) { - var errorPayload = await response.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(errorPayload)) + using var errorBuffer = await ReadResponseBytesAsync(response, cancellationToken); + if (errorBuffer.Length == 0) { Logger.RequestFailed("No payload present"); return new ApiResponse(new ApiError("Request not successful.") { StatusCode = response.StatusCode }); } + var errorPayload = Encoding.UTF8.GetString(errorBuffer.Span); var error = DeserializeXmlError(errorPayload); if (error is not null) { @@ -101,7 +102,8 @@ private async Task> ProcessXmlResponseAsync(Ht return new ApiResponse(error ?? _deserializationError); } - var contentPayload = await response.Content.ReadAsStringAsync(); + using var pooledBuffer = await ReadResponseBytesAsync(response, cancellationToken); + var contentPayload = pooledBuffer.Length > 0 ? Encoding.UTF8.GetString(pooledBuffer.Span) : string.Empty; Debug.WriteLine("RESPONSE: " + contentPayload); // Handle string responses specially diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index aeda16ae..966021fe 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -53,7 +53,7 @@ public async Task> PublishAsync(PublishRequest request, var httpResponse = await SendAsync(requestMessage, "Publish", cancellationToken); // Process the response - var apiResponse = await ProcessSnsResponseAsync(httpResponse); + var apiResponse = await ProcessSnsResponseAsync(httpResponse, cancellationToken); return ConvertApiResponse(apiResponse); } @@ -126,20 +126,21 @@ private static string SerializeToQueryParameters(PublishRequest request) /// /// Processes an SNS HTTP response and converts it to an API response. /// - private async Task> ProcessSnsResponseAsync(HttpResponseMessage response) + private async Task> ProcessSnsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where TResponse : class, IDeserializeFromXml, new() { var headers = ResponseHeaders.FromHttpResponse(response.Headers); if (!response.IsSuccessStatusCode) { - var errorPayload = await response.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(errorPayload)) + using var errorBuffer = await ReadResponseBytesAsync(response, cancellationToken); + if (errorBuffer.Length == 0) { Logger.RequestFailed("No payload present"); return new ApiResponse(new ApiError("Request not successful.") { StatusCode = response.StatusCode }); } + var errorPayload = Encoding.UTF8.GetString(errorBuffer.Span); var error = DeserializeSnsError(errorPayload); if (error is not null) { @@ -161,7 +162,8 @@ private async Task> ProcessSnsResponseAsync(Ht return new ApiResponse(error ?? DeserializationError); } - var contentPayload = await response.Content.ReadAsStringAsync(); + using var pooledBuffer = await ReadResponseBytesAsync(response, cancellationToken); + var contentPayload = pooledBuffer.Length > 0 ? Encoding.UTF8.GetString(pooledBuffer.Span) : string.Empty; if (string.IsNullOrWhiteSpace(contentPayload)) { From 591461f09c67a99a38fd33593a85b11fd85b0ee2 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 12:53:34 +0000 Subject: [PATCH 06/34] perf: Avoid byte[] allocation in ApiErrorReader Add ReadOnlySpan overload to ApiErrorReader and use it directly from ProcessJsonResponseAsync to skip the string->bytes round-trip. The string overload now uses stackalloc for payloads <= 1KB and ArrayPool for larger ones. --- .../Goa.Clients.Core/Http/ApiErrorReader.cs | 44 ++++++++++++++++--- .../Goa.Clients.Core/JsonAwsServiceClient.cs | 2 +- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs b/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs index 2ddd6470..af09a609 100644 --- a/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs +++ b/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Text; using System.Text.Json; namespace Goa.Clients.Core.Http; @@ -8,19 +10,19 @@ namespace Goa.Clients.Core.Http; internal static class ApiErrorReader { /// - /// Reads an ApiError from a JSON string using Utf8JsonReader. + /// Reads an ApiError from UTF-8 bytes using Utf8JsonReader. /// Matches fields case-insensitively: "message", "__type", "code". /// - /// The JSON string to deserialize. + /// The UTF-8 encoded JSON bytes to deserialize. /// The deserialized ApiError, or null if the input is malformed or empty. - public static ApiError? ReadApiError(string content) + public static ApiError? ReadApiError(ReadOnlySpan utf8Json) { - if (string.IsNullOrWhiteSpace(content)) + if (utf8Json.IsEmpty) return null; try { - var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(content)); + var reader = new Utf8JsonReader(utf8Json); if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) return null; @@ -68,4 +70,36 @@ internal static class ApiErrorReader return null; } } + + /// + /// Reads an ApiError from a JSON string using Utf8JsonReader. + /// Uses stackalloc for small payloads, ArrayPool for larger ones. + /// + /// The JSON string to deserialize. + /// The deserialized ApiError, or null if the input is malformed or empty. + public static ApiError? ReadApiError(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + var byteCount = Encoding.UTF8.GetByteCount(content); + + if (byteCount <= 1024) + { + Span buffer = stackalloc byte[byteCount]; + Encoding.UTF8.GetBytes(content, buffer); + return ReadApiError((ReadOnlySpan)buffer); + } + + var rented = ArrayPool.Shared.Rent(byteCount); + try + { + var written = Encoding.UTF8.GetBytes(content, rented); + return ReadApiError(rented.AsSpan(0, written)); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } } diff --git a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs index cca8c6c3..ebd71225 100644 --- a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs @@ -94,8 +94,8 @@ protected async Task> ProcessJsonResponseAsync return new ApiResponse(new ApiError("Request not successful.") { StatusCode = response.StatusCode }); } + var error = ApiErrorReader.ReadApiError(errorBuffer.Span); var errorPayload = Encoding.UTF8.GetString(errorBuffer.Span); - var error = DeserializeJsonError(errorPayload); if (error is not null) { error = error with From 11269ca021910f5b495feab8dd89e281195e6d7a Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 13:47:13 +0000 Subject: [PATCH 07/34] Fixed benchmark --- .../Benchmarks/QueryAsyncBenchmarks.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index 194ca5e8..1debe643 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -40,6 +40,8 @@ public class QueryAsyncBenchmarks [GlobalSetup] public void Setup() { + DynamoReaderRegistration.Initialize(); + _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); From ad782753dbbc2eae69ddcf12775eecd80d6fac0b Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:03:56 +0000 Subject: [PATCH 08/34] perf: Add shared Throw helper class for DoesNotReturn throw extraction --- src/Clients/Goa.Clients.Core/Throw.cs | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/Clients/Goa.Clients.Core/Throw.cs diff --git a/src/Clients/Goa.Clients.Core/Throw.cs b/src/Clients/Goa.Clients.Core/Throw.cs new file mode 100644 index 00000000..7edff30b --- /dev/null +++ b/src/Clients/Goa.Clients.Core/Throw.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace Goa.Clients.Core; + +internal static class Throw +{ + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void IndexOutOfRange() => throw new IndexOutOfRangeException(); + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void InvalidOperation(string message) => throw new InvalidOperationException(message); + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ArgumentNull(string paramName) => throw new ArgumentNullException(paramName); + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static T InvalidOperation(string message) => throw new InvalidOperationException(message); + + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static T JsonException(string message) => throw new JsonException(message); +} From 9576af1724ba20e47963cddf3140e9c7e3d8fd3f Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:04:27 +0000 Subject: [PATCH 09/34] perf: Extract LogScope indexer throws into Throw.IndexOutOfRange helper --- .../Goa.Clients.Core/Logging/LogScope.cs | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Logging/LogScope.cs b/src/Clients/Goa.Clients.Core/Logging/LogScope.cs index 6959f738..7808ac2a 100644 --- a/src/Clients/Goa.Clients.Core/Logging/LogScope.cs +++ b/src/Clients/Goa.Clients.Core/Logging/LogScope.cs @@ -22,12 +22,14 @@ public LogScope8( public int Count => 8; - public KeyValuePair this[int index] => index switch + public KeyValuePair this[int index] { - 0 => _0, 1 => _1, 2 => _2, 3 => _3, - 4 => _4, 5 => _5, 6 => _6, 7 => _7, - _ => throw new IndexOutOfRangeException() - }; + get + { + if ((uint)index >= 8) Throw.IndexOutOfRange(); + return index switch { 0 => _0, 1 => _1, 2 => _2, 3 => _3, 4 => _4, 5 => _5, 6 => _6, _ => _7 }; + } + } public Enumerator GetEnumerator() => new(this); IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); @@ -63,11 +65,14 @@ public LogScope2(KeyValuePair p0, KeyValuePair p public int Count => 2; - public KeyValuePair this[int index] => index switch + public KeyValuePair this[int index] { - 0 => _0, 1 => _1, - _ => throw new IndexOutOfRangeException() - }; + get + { + if ((uint)index >= 2) Throw.IndexOutOfRange(); + return index switch { 0 => _0, _ => _1 }; + } + } public Enumerator GetEnumerator() => new(this); IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); @@ -122,12 +127,14 @@ public LogScope4( public int Count => _count; - public KeyValuePair this[int index] => - index < _count ? index switch + public KeyValuePair this[int index] + { + get { - 0 => _0, 1 => _1, 2 => _2, 3 => _3, - _ => throw new IndexOutOfRangeException() - } : throw new IndexOutOfRangeException(); + if ((uint)index >= (uint)_count) Throw.IndexOutOfRange(); + return index switch { 0 => _0, 1 => _1, 2 => _2, _ => _3 }; + } + } public Enumerator GetEnumerator() => new(this); IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); From a009a21f686a5ea8ab2f9899b4f87f1ce1182653 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:17:49 +0000 Subject: [PATCH 10/34] perf: Use Throw.JsonException in Bedrock JSON converters --- .../Serialization/ConversationRoleConverter.cs | 5 +++-- .../Serialization/GuardrailTextQualifierConverter.cs | 5 +++-- .../Serialization/LatencyModeConverter.cs | 5 +++-- .../Serialization/ServiceTierConverter.cs | 5 +++-- .../Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs | 5 +++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs index e11587ad..38c2d593 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Enums; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -18,7 +19,7 @@ public override ConversationRole Read(ref Utf8JsonReader reader, Type typeToConv { "user" => ConversationRole.User, "assistant" => ConversationRole.Assistant, - _ => throw new JsonException($"Unknown ConversationRole: {value}") + _ => Throw.JsonException($"Unknown ConversationRole: {value}") }; } @@ -29,7 +30,7 @@ public override void Write(Utf8JsonWriter writer, ConversationRole value, JsonSe { ConversationRole.User => "user", ConversationRole.Assistant => "assistant", - _ => throw new JsonException($"Unknown ConversationRole: {value}") + _ => Throw.JsonException($"Unknown ConversationRole: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs index 64b999e0..3eeaf783 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Operations.ApplyGuardrail; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -19,7 +20,7 @@ public override GuardrailTextQualifier Read(ref Utf8JsonReader reader, Type type "grounding_source" => GuardrailTextQualifier.GroundingSource, "query" => GuardrailTextQualifier.Query, "guard_content" => GuardrailTextQualifier.GuardContent, - _ => throw new JsonException($"Unknown GuardrailTextQualifier: {value}") + _ => Throw.JsonException($"Unknown GuardrailTextQualifier: {value}") }; } @@ -31,7 +32,7 @@ public override void Write(Utf8JsonWriter writer, GuardrailTextQualifier value, GuardrailTextQualifier.GroundingSource => "grounding_source", GuardrailTextQualifier.Query => "query", GuardrailTextQualifier.GuardContent => "guard_content", - _ => throw new JsonException($"Unknown GuardrailTextQualifier: {value}") + _ => Throw.JsonException($"Unknown GuardrailTextQualifier: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs index 21abd775..8101ef44 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Enums; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -18,7 +19,7 @@ public override LatencyMode Read(ref Utf8JsonReader reader, Type typeToConvert, { "standard" => LatencyMode.Standard, "optimized" => LatencyMode.Optimized, - _ => throw new JsonException($"Unknown LatencyMode: {value}") + _ => Throw.JsonException($"Unknown LatencyMode: {value}") }; } @@ -29,7 +30,7 @@ public override void Write(Utf8JsonWriter writer, LatencyMode value, JsonSeriali { LatencyMode.Standard => "standard", LatencyMode.Optimized => "optimized", - _ => throw new JsonException($"Unknown LatencyMode: {value}") + _ => Throw.JsonException($"Unknown LatencyMode: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs index 8db81f37..fad624aa 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Models; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -19,7 +20,7 @@ public override ServiceTier Read(ref Utf8JsonReader reader, Type typeToConvert, "default" => ServiceTier.Default, "priority" => ServiceTier.Priority, "flex" => ServiceTier.Flex, - _ => throw new JsonException($"Unknown ServiceTier: {value}") + _ => Throw.JsonException($"Unknown ServiceTier: {value}") }; } @@ -31,7 +32,7 @@ public override void Write(Utf8JsonWriter writer, ServiceTier value, JsonSeriali ServiceTier.Default => "default", ServiceTier.Priority => "priority", ServiceTier.Flex => "flex", - _ => throw new JsonException($"Unknown ServiceTier: {value}") + _ => Throw.JsonException($"Unknown ServiceTier: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs index ba29002a..da0ac771 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Enums; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -23,7 +24,7 @@ public override StopReason Read(ref Utf8JsonReader reader, Type typeToConvert, J "guardrail_intervened" => StopReason.GuardrailIntervened, "content_filtered" => StopReason.ContentFiltered, "model_context_window_exceeded" => StopReason.ModelContextWindowExceeded, - _ => throw new JsonException($"Unknown StopReason: {value}") + _ => Throw.JsonException($"Unknown StopReason: {value}") }; } @@ -39,7 +40,7 @@ public override void Write(Utf8JsonWriter writer, StopReason value, JsonSerializ StopReason.GuardrailIntervened => "guardrail_intervened", StopReason.ContentFiltered => "content_filtered", StopReason.ModelContextWindowExceeded => "model_context_window_exceeded", - _ => throw new JsonException($"Unknown StopReason: {value}") + _ => Throw.JsonException($"Unknown StopReason: {value}") }; writer.WriteStringValue(stringValue); } From 51752d724a4a08bf5ebf42e4e7bf76724eba0a3f Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:19:02 +0000 Subject: [PATCH 11/34] perf: Add [DoesNotReturn] to MissingAttributeException.Throw methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant [MethodImpl(NoInlining)] — the JIT already avoids inlining methods annotated with [DoesNotReturn]. --- src/Clients/Goa.Clients.Core/Throw.cs | 11 +++++------ .../Exceptions/MissingAttributeException.cs | 10 ++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Throw.cs b/src/Clients/Goa.Clients.Core/Throw.cs index 7edff30b..101bbe0a 100644 --- a/src/Clients/Goa.Clients.Core/Throw.cs +++ b/src/Clients/Goa.Clients.Core/Throw.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using System.Text.Json; namespace Goa.Clients.Core; @@ -7,22 +6,22 @@ namespace Goa.Clients.Core; internal static class Throw { [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] + public static void IndexOutOfRange() => throw new IndexOutOfRangeException(); [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] + public static void InvalidOperation(string message) => throw new InvalidOperationException(message); [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] + public static void ArgumentNull(string paramName) => throw new ArgumentNullException(paramName); [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] + public static T InvalidOperation(string message) => throw new InvalidOperationException(message); [DoesNotReturn] - [MethodImpl(MethodImplOptions.NoInlining)] + public static T JsonException(string message) => throw new JsonException(message); } diff --git a/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs b/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs index 4a5120fe..9d641d89 100644 --- a/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs +++ b/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Goa.Clients.Dynamo.Exceptions; /// @@ -70,6 +72,8 @@ public MissingAttributeException(string attributeName, Exception innerException, /// The name of the missing attribute. /// Never returns (always throws). /// Always thrown. + [DoesNotReturn] + public static T Throw(string attributeName) { throw new MissingAttributeException(attributeName); @@ -83,6 +87,8 @@ public static T Throw(string attributeName) /// The sort key value for context. /// Never returns (always throws). /// Always thrown. + [DoesNotReturn] + public static T Throw(string attributeName, string? partitionKey, string? sortKey) { throw new MissingAttributeException(attributeName, partitionKey, sortKey); @@ -94,6 +100,8 @@ public static T Throw(string attributeName, string? partitionKey, string? sor /// /// The name of the missing attribute. /// Always thrown. + [DoesNotReturn] + public static void Throw(string attributeName) { throw new MissingAttributeException(attributeName); @@ -107,6 +115,8 @@ public static void Throw(string attributeName) /// The partition key value for context. /// The sort key value for context. /// Always thrown. + [DoesNotReturn] + public static void Throw(string attributeName, string? partitionKey, string? sortKey) { throw new MissingAttributeException(attributeName, partitionKey, sortKey); From c6c433565cafb9b5958688a712b41192303bcc19 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:19:44 +0000 Subject: [PATCH 12/34] perf: Extract DynamoPaginationException throws into [DoesNotReturn] helper --- .../Goa.Clients.Dynamo/DynamoExtensions.cs | 16 ++++++++-------- .../Exceptions/DynamoPaginationException.cs | 4 ++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs index d244a479..66263dc9 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs @@ -163,7 +163,7 @@ public static async IAsyncEnumerable QueryAllAsync(this IDynamoCli var result = await client.QueryAsync(request, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -200,7 +200,7 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClie var result = await client.ScanAsync(request, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -232,7 +232,7 @@ public static async IAsyncEnumerable QueryAllAsync(this IDynamoClient clie var result = await client.QueryAsync(request, itemReader, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -264,7 +264,7 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClient clien var result = await client.ScanAsync(request, itemReader, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -302,7 +302,7 @@ public static async IAsyncEnumerable> BatchGe var result = await client.BatchGetItemAsync(request, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var tableResponse in result.Value.Responses) @@ -320,7 +320,7 @@ public static async IAsyncEnumerable> BatchGe if (attempts >= maxAttempts) { - throw new DynamoPaginationException( + DynamoPaginationException.Throw( ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts.")); } @@ -350,7 +350,7 @@ public static async IAsyncEnumerable> BatchGetAllAsync> BatchGetAllAsync= maxAttempts) { - throw new DynamoPaginationException( + DynamoPaginationException.Throw( ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts.")); } diff --git a/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs b/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs index 4c55d244..0b3c28a6 100644 --- a/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs +++ b/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using ErrorOr; namespace Goa.Clients.Dynamo.Exceptions; @@ -33,4 +34,7 @@ public DynamoPaginationException(Error error, Exception innerException) { Error = error; } + + [DoesNotReturn] + internal static void Throw(Error error) => throw new DynamoPaginationException(error); } From 9b0c7e86097bb3d1ba0654271add5f09145c4c27 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:20:14 +0000 Subject: [PATCH 13/34] perf: Use Throw.InvalidOperation in AwsServiceClient.ReadResponseBytesAsync --- src/Clients/Goa.Clients.Core/AwsServiceClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index bc3dde0e..2f2c0e14 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -267,7 +267,7 @@ protected static async Task ReadResponseBytesAsync( if (headerLength is not null) { if (headerLength < 0 || headerLength > MaxResponseSize) - throw new InvalidOperationException($"Response Content-Length {headerLength} exceeds maximum {MaxResponseSize} bytes."); + Throw.InvalidOperation($"Response Content-Length {headerLength} exceeds maximum {MaxResponseSize} bytes."); var contentLength = (int)headerLength; var knownBuffer = ArrayPool.Shared.Rent(contentLength); @@ -315,7 +315,7 @@ protected static async Task ReadResponseBytesAsync( totalRead += read; if (totalRead > MaxResponseSize) - throw new InvalidOperationException($"Response size exceeds maximum {MaxResponseSize} bytes."); + Throw.InvalidOperation($"Response size exceeds maximum {MaxResponseSize} bytes."); } if (totalRead == 0) From 393c94811e3777018e31b8e252ede594d8039032 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:20:40 +0000 Subject: [PATCH 14/34] perf: Replace RequestSigner private throw helpers with shared Throw class --- .../Goa.Clients.Core/Http/RequestSigner.cs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs index ba3ed03c..be4a273b 100644 --- a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs +++ b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs @@ -59,7 +59,7 @@ public async ValueTask SignRequestAsync(HttpRequestMessage request, Date if (i != 0) sb.Append("; "); sb.Append(errs[i].Description); } - ThrowInvalidOperationException($"Failed to retrieve AWS credentials: {sb}"); + Throw.InvalidOperation($"Failed to retrieve AWS credentials: {sb}"); } var credentials = credentialsResult.Value; @@ -146,7 +146,7 @@ private async ValueTask SignRequestWithCredentialsAsync( if (i != 0) sb.Append("; "); sb.Append(errs[i].Description); } - ThrowInvalidOperationException($"Failed to retrieve AWS credentials: {sb}"); + Throw.InvalidOperation($"Failed to retrieve AWS credentials: {sb}"); } var credentials = credentialsResult.Value; @@ -885,10 +885,10 @@ private static (string region, string serviceName) GetRequestParameters(HttpRequ region = regionOption; if (string.IsNullOrWhiteSpace(region)) - ThrowInvalidOperationException("Region is required"); + Throw.InvalidOperation("Region is required"); if (!request.Options.TryGetValue(HttpOptions.Service, out var service) || string.IsNullOrWhiteSpace(service)) - ThrowInvalidOperationException("Service name is required"); + Throw.InvalidOperation("Service name is required"); return (region!, service); } @@ -1109,17 +1109,6 @@ private static bool IsAwsManagedHeader(string headerName) string.Equals(headerName, "x-amz-security-token", StringComparison.OrdinalIgnoreCase); } - /// - /// Exception helpers with no-inlining to keep hot paths small. - /// - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - private static void ThrowInvalidOperationException(string message) => throw new InvalidOperationException(message); - - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - private static void ThrowArgumentNullException(string paramName) => throw new ArgumentNullException(paramName); - /// /// Creates credential scope string for AWS signature computation. /// From 2b5671640ddbb0927b5561c0450dd02163026256 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:21:47 +0000 Subject: [PATCH 15/34] perf: Update generator templates to emit Throw helpers in generated code --- .../CodeGeneration/JsonMapperGenerator.cs | 7 ++++--- .../CodeGeneration/MapperGenerator.cs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs index a44966d1..b56f92c2 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -32,6 +32,7 @@ public string GenerateCode(IEnumerable types, GenerationContext builder.AppendLine("using System.Collections.Generic;"); builder.AppendLine("using System.Globalization;"); builder.AppendLine("using System.Text.Json;"); + builder.AppendLine("using Goa.Clients.Core;"); foreach (var ns in typesByNamespace) { @@ -113,7 +114,7 @@ private void GenerateAbstractWriteDispatch(CodeBuilder builder, DynamoTypeInfo t builder.AppendLine("default:"); builder.Indent(); - builder.AppendLine($"throw new InvalidOperationException($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\");"); + builder.AppendLine($"Throw.InvalidOperation($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\");"); builder.Unindent(); builder.CloseBrace(); } @@ -527,7 +528,7 @@ private void GenerateAbstractReadDispatch(CodeBuilder builder, DynamoTypeInfo ty builder.CloseBrace(); builder.AppendLine(); builder.AppendLine($"if (typeDiscriminator == null)"); - builder.Indent().AppendLine($"throw new InvalidOperationException(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); + builder.Indent().AppendLine($"Throw.InvalidOperation(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); builder.AppendLine(); builder.OpenBraceWithLine("return typeDiscriminator switch"); @@ -540,7 +541,7 @@ private void GenerateAbstractReadDispatch(CodeBuilder builder, DynamoTypeInfo ty } } - builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown type: {{typeDiscriminator}} for abstract type {type.FullName}\")"); + builder.AppendLine($"_ => Throw.InvalidOperation<{type.FullName}>($\"Unknown type: {{typeDiscriminator}} for abstract type {type.FullName}\")"); builder.CloseBrace().Append(";"); builder.AppendLine(); } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs index 3a0487b3..6381b55a 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs @@ -37,6 +37,7 @@ public string GenerateCode(IEnumerable types, GenerationContext builder.AppendLine("using Goa.Clients.Dynamo.Models;"); builder.AppendLine("using Goa.Clients.Dynamo.Exceptions;"); builder.AppendLine("using Goa.Clients.Dynamo.Extensions;"); + builder.AppendLine("using Goa.Clients.Core;"); foreach (var ns in typesByNamespace) { @@ -135,7 +136,7 @@ private void GenerateAbstractToDynamoRecordDispatch(CodeBuilder builder, DynamoT builder.AppendLine($"{concreteType.FullName} concrete => DynamoMapper.{normalizedTypeName}.ToDynamoRecord(concrete),"); } - builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\")"); + builder.AppendLine($"_ => Throw.InvalidOperation($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\")"); builder.CloseBrace().Append(";"); } @@ -378,7 +379,7 @@ private void GenerateAbstractTypeDispatch(CodeBuilder builder, DynamoTypeInfo ty var typeNameField = dynamoModelAttr?.TypeName ?? "Type"; builder.AppendLine($"if (!record.TryGetNullableString(\"{typeNameField}\", out var typeValue) || typeValue == null)"); - builder.Indent().AppendLine($"throw new InvalidOperationException(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); + builder.Indent().AppendLine($"Throw.InvalidOperation(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); builder.AppendLine(); builder.OpenBraceWithLine("return typeValue switch"); @@ -391,7 +392,7 @@ private void GenerateAbstractTypeDispatch(CodeBuilder builder, DynamoTypeInfo ty } } - builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown type: {{typeValue}} for abstract type {type.FullName}\")"); + builder.AppendLine($"_ => Throw.InvalidOperation<{type.FullName}>($\"Unknown type: {{typeValue}} for abstract type {type.FullName}\")"); builder.CloseBrace().Append(";"); } From 63448cffd3c24bd86e8a664c5cec2b2b1a1d0f07 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 14:48:18 +0000 Subject: [PATCH 16/34] fix: Make Throw class public for generated code and fix switch fall-through --- src/Clients/Goa.Clients.Core/Throw.cs | 16 ++++++++++------ .../CodeGeneration/JsonMapperGenerator.cs | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Throw.cs b/src/Clients/Goa.Clients.Core/Throw.cs index 101bbe0a..5dbaaa30 100644 --- a/src/Clients/Goa.Clients.Core/Throw.cs +++ b/src/Clients/Goa.Clients.Core/Throw.cs @@ -3,25 +3,29 @@ namespace Goa.Clients.Core; -internal static class Throw +/// +/// Shared throw helpers annotated with so the JIT +/// keeps throw-site code out of the inlined hot path. +/// +public static class Throw { + /// Throws . [DoesNotReturn] - public static void IndexOutOfRange() => throw new IndexOutOfRangeException(); + /// Throws with the specified message. [DoesNotReturn] - public static void InvalidOperation(string message) => throw new InvalidOperationException(message); + /// Throws for the specified parameter. [DoesNotReturn] - public static void ArgumentNull(string paramName) => throw new ArgumentNullException(paramName); + /// Throws . Returns for use in switch expression arms. [DoesNotReturn] - public static T InvalidOperation(string message) => throw new InvalidOperationException(message); + /// Throws . Returns for use in switch expression arms. [DoesNotReturn] - public static T JsonException(string message) => throw new JsonException(message); } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs index b56f92c2..a1c533f1 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -115,6 +115,7 @@ private void GenerateAbstractWriteDispatch(CodeBuilder builder, DynamoTypeInfo t builder.AppendLine("default:"); builder.Indent(); builder.AppendLine($"Throw.InvalidOperation($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\");"); + builder.AppendLine("return;"); builder.Unindent(); builder.CloseBrace(); } From 03d9e14960aeb50d61651d05e7886bbc5c380c7b Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 15:08:02 +0000 Subject: [PATCH 17/34] test: Update generator test assertion to match Throw helper pattern --- .../CodeGeneration/MapperGeneratorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs index 01f08637..8e85b24b 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs @@ -602,7 +602,7 @@ await Assert.That(result) await Assert.That(result) .Contains("TestNamespace.ConcreteEntity concrete => DynamoMapper.ConcreteEntity.ToDynamoRecord(concrete),"); await Assert.That(result) - .Contains("_ => throw new InvalidOperationException($\"Unknown concrete type: {model.GetType().FullName} for abstract type TestNamespace.BaseEntity\")"); + .Contains("_ => Throw.InvalidOperation($\"Unknown concrete type: {model.GetType().FullName} for abstract type TestNamespace.BaseEntity\")"); } private static TypeHandlerRegistry CreateTypeHandlerRegistry() From 8d1fbcde8d58fd8080fb38fe388807a304f998c7 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 15:53:37 +0000 Subject: [PATCH 18/34] perf: Guard BeginScope calls with log-level check in AwsServiceClient Eliminates 4 heap allocations + 2 AsyncLocal writes per request when logging is disabled by wrapping both BeginScope calls with Logger.IsEnabled(Configuration.LogLevel). --- .../Goa.Clients.Core/AwsServiceClient.cs | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index 2f2c0e14..394c969c 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -68,16 +68,18 @@ protected async Task SendAsync(HttpRequestMessage request, request.Options.Set(HttpOptions.Target, target); request.Options.Set(HttpOptions.ApiVersion, Configuration.ApiVersion); - using var logContext = Logger.BeginScope(new LogScope8( - new("Client", _clientType), - new("Region", Configuration.Region), - new("Service", Configuration.Service), - new("SigningService", Configuration.SigningService), - new("Target", target), - new("ApiVersion", Configuration.ApiVersion), - new("Method", request.Method.Method), - new("Uri", request.RequestUri?.AbsoluteUri ?? "Unknown") - )); + using var logContext = Logger.IsEnabled(Configuration.LogLevel) + ? Logger.BeginScope(new LogScope8( + new("Client", _clientType), + new("Region", Configuration.Region), + new("Service", Configuration.Service), + new("SigningService", Configuration.SigningService), + new("Target", target), + new("ApiVersion", Configuration.ApiVersion), + new("Method", request.Method.Method), + new("Uri", request.RequestUri?.AbsoluteUri ?? "Unknown") + )) + : null; try { @@ -87,10 +89,12 @@ protected async Task SendAsync(HttpRequestMessage request, var response = await client.SendAsync(request, cancellationToken); // Log fixed response fields with zero-allocation scope - using var responseLogContext = Logger.BeginScope(new LogScope2( - new("StatusCode", ((int)response.StatusCode).ToString()), - new("ReasonPhrase", response.ReasonPhrase ?? response.StatusCode.ToString()) - )); + using var responseLogContext = Logger.IsEnabled(Configuration.LogLevel) + ? Logger.BeginScope(new LogScope2( + new("StatusCode", ((int)response.StatusCode).ToString()), + new("ReasonPhrase", response.ReasonPhrase ?? response.StatusCode.ToString()) + )) + : null; // Log x-amz response headers separately with capacity hint Dictionary? amzHeaders = null; From 366d74d5e968e1cc367b145a37af7e24421de42f Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 16:04:51 +0000 Subject: [PATCH 19/34] perf: Cache MediaTypeHeaderValue in XmlAwsServiceClient and SnsServiceClient Follows the existing pattern in JsonAwsServiceClient to avoid allocating a new MediaTypeHeaderValue instance per request. --- src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs | 3 ++- src/Clients/Goa.Clients.Sns/SnsServiceClient.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs index b3c691b7..294a37d6 100644 --- a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs @@ -15,6 +15,7 @@ namespace Goa.Clients.Core; /// The configuration type that extends AwsServiceConfiguration. public abstract class XmlAwsServiceClient : AwsServiceClient where T : AwsServiceConfiguration { + private static readonly MediaTypeHeaderValue XmlContentType = new("application/xml"); private readonly ApiError _deserializationError = new("Failed to deserialize response", "DeserializationError"); /// @@ -57,7 +58,7 @@ protected async Task> SendAsync(Http } } - using var requestMessage = CreateRequestMessage(method, requestUri + $"?Action={UrlEncoder.Default.Encode(target)}", content, new MediaTypeHeaderValue("application/xml"), headers); + using var requestMessage = CreateRequestMessage(method, requestUri + $"?Action={UrlEncoder.Default.Encode(target)}", content, XmlContentType, headers); using var response = await SendAsync(requestMessage, target, cancellationToken); return await ProcessXmlResponseAsync(response, cancellationToken); diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index 966021fe..2b49e677 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -11,6 +11,7 @@ namespace Goa.Clients.Sns; internal sealed class SnsServiceClient : AwsServiceClient, ISnsClient { + private static readonly MediaTypeHeaderValue FormUrlEncodedContentType = new("application/x-www-form-urlencoded"); private static readonly ApiError DeserializationError = new("Failed to deserialize response", "DeserializationError"); public SnsServiceClient( @@ -47,7 +48,7 @@ public async Task> PublishAsync(PublishRequest request, HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-www-form-urlencoded")); + FormUrlEncodedContentType); // Send request and get raw HTTP response var httpResponse = await SendAsync(requestMessage, "Publish", cancellationToken); From 882da08c52e65ccd4624a68a037bbdcde400cc27 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 16:05:17 +0000 Subject: [PATCH 20/34] perf: Cache common HTTP status code strings in AwsServiceClient Avoids int.ToString() allocation per response for the most common HTTP status codes by returning cached string literals from a switch expression. --- src/Clients/Goa.Clients.Core/AwsServiceClient.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index 394c969c..6905e00a 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -91,7 +91,7 @@ protected async Task SendAsync(HttpRequestMessage request, // Log fixed response fields with zero-allocation scope using var responseLogContext = Logger.IsEnabled(Configuration.LogLevel) ? Logger.BeginScope(new LogScope2( - new("StatusCode", ((int)response.StatusCode).ToString()), + new("StatusCode", GetStatusCodeString((int)response.StatusCode)), new("ReasonPhrase", response.ReasonPhrase ?? response.StatusCode.ToString()) )) : null; @@ -149,6 +149,16 @@ protected static ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, A return error; } + private static string GetStatusCodeString(int statusCode) => statusCode switch + { + 200 => "200", 201 => "201", 202 => "202", 204 => "204", + 301 => "301", 302 => "302", 304 => "304", + 400 => "400", 401 => "401", 403 => "403", 404 => "404", + 409 => "409", 429 => "429", + 500 => "500", 502 => "502", 503 => "503", 504 => "504", + _ => statusCode.ToString() + }; + /// /// Creates an HTTP request message with the specified content. /// From 5c919520bdf496c26d3330a771c878f0eeb0a573 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 16:05:54 +0000 Subject: [PATCH 21/34] refactor: Extract AwsServiceClient log scope key names to constants Replaces magic string literals with named constants for log scope keys, improving deduplication and readability. --- .../Goa.Clients.Core/AwsServiceClient.cs | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index 6905e00a..cb7f4f1a 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -24,6 +24,17 @@ public abstract class AwsServiceClient where T : AwsServiceConfiguration /// public const string XAmzErrorType = "x-amzn-ErrorType"; + private const string LogKeyClient = "Client"; + private const string LogKeyRegion = "Region"; + private const string LogKeyService = "Service"; + private const string LogKeySigningService = "SigningService"; + private const string LogKeyTarget = "Target"; + private const string LogKeyApiVersion = "ApiVersion"; + private const string LogKeyMethod = "Method"; + private const string LogKeyUri = "Uri"; + private const string LogKeyStatusCode = "StatusCode"; + private const string LogKeyReasonPhrase = "ReasonPhrase"; + private readonly IHttpClientFactory _httpClientFactory; private readonly string _clientType; private Uri? _cachedBaseUri; @@ -70,14 +81,14 @@ protected async Task SendAsync(HttpRequestMessage request, using var logContext = Logger.IsEnabled(Configuration.LogLevel) ? Logger.BeginScope(new LogScope8( - new("Client", _clientType), - new("Region", Configuration.Region), - new("Service", Configuration.Service), - new("SigningService", Configuration.SigningService), - new("Target", target), - new("ApiVersion", Configuration.ApiVersion), - new("Method", request.Method.Method), - new("Uri", request.RequestUri?.AbsoluteUri ?? "Unknown") + new(LogKeyClient, _clientType), + new(LogKeyRegion, Configuration.Region), + new(LogKeyService, Configuration.Service), + new(LogKeySigningService, Configuration.SigningService), + new(LogKeyTarget, target), + new(LogKeyApiVersion, Configuration.ApiVersion), + new(LogKeyMethod, request.Method.Method), + new(LogKeyUri, request.RequestUri?.AbsoluteUri ?? "Unknown") )) : null; @@ -91,8 +102,8 @@ protected async Task SendAsync(HttpRequestMessage request, // Log fixed response fields with zero-allocation scope using var responseLogContext = Logger.IsEnabled(Configuration.LogLevel) ? Logger.BeginScope(new LogScope2( - new("StatusCode", GetStatusCodeString((int)response.StatusCode)), - new("ReasonPhrase", response.ReasonPhrase ?? response.StatusCode.ToString()) + new(LogKeyStatusCode, GetStatusCodeString((int)response.StatusCode)), + new(LogKeyReasonPhrase, response.ReasonPhrase ?? response.StatusCode.ToString()) )) : null; From 2844f0d67cbcbeb910d8b77abd6e35d9747ff388 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 16:06:46 +0000 Subject: [PATCH 22/34] refactor: Extract ResponseHeaders header name constants Replaces inline header name strings with named constants for deduplication and readability. --- .../Goa.Clients.Core/Http/ResponseHeaders.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs b/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs index 40e54db1..497315be 100644 --- a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs +++ b/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs @@ -7,6 +7,11 @@ namespace Goa.Clients.Core.Http; /// public sealed class ResponseHeaders { + private const string AmznRequestId = "x-amzn-RequestId"; + private const string AmzRequestId = "x-amz-request-id"; + private const string AmznErrorType = "x-amzn-ErrorType"; + private const string AmznErrorMessage = "x-amzn-ErrorMessage"; + /// /// Gets the AWS request ID from the response headers. /// @@ -33,20 +38,20 @@ public sealed class ResponseHeaders internal static ResponseHeaders FromHttpResponse(HttpResponseHeaders headers, HttpContentHeaders? contentHeaders = null) { string? requestId = null; - if (headers.TryGetValues("x-amzn-RequestId", out var values) - || headers.TryGetValues("x-amz-request-id", out values)) + if (headers.TryGetValues(AmznRequestId, out var values) + || headers.TryGetValues(AmzRequestId, out values)) { requestId = values.FirstOrDefault(); } string? errorType = null; - if (headers.TryGetValues("x-amzn-ErrorType", out var typeValues)) + if (headers.TryGetValues(AmznErrorType, out var typeValues)) { errorType = typeValues.FirstOrDefault(); } string? errorMessage = null; - if (headers.TryGetValues("x-amzn-ErrorMessage", out var errorValues)) + if (headers.TryGetValues(AmznErrorMessage, out var errorValues)) { errorMessage = errorValues.FirstOrDefault(); } From 1f264b43732819b4a3f08de06acbd13190f4f62f Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 16:07:24 +0000 Subject: [PATCH 23/34] refactor: Extract RequestSigner header name constants Deduplicates header name strings between HeaderRef.Create calls and IsAwsManagedHeader by introducing named constants. --- .../Goa.Clients.Core/Http/RequestSigner.cs | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs index be4a273b..3164a1a3 100644 --- a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs +++ b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs @@ -29,6 +29,12 @@ internal sealed class RequestSigner private static readonly string? _cachedAwsRegion = Environment.GetEnvironmentVariable("AWS_REGION"); private const string RootPath = "/"; private const string CredentialSuffix = "/aws4_request"; + private const string HeaderHost = "host"; + private const string HeaderAmzContentSha256 = "x-amz-content-sha256"; + private const string HeaderAmzDate = "x-amz-date"; + private const string HeaderAmzTarget = "x-amz-target"; + private const string HeaderAmzApiVersion = "x-amz-api-version"; + private const string HeaderAmzSecurityToken = "x-amz-security-token"; private readonly ICredentialProviderChain _credentialProvider; @@ -268,13 +274,13 @@ private async ValueTask SignRequestWithCredentialsAsync( // Build AWS required headers first if (request.RequestUri is not null) - headerRefs[hrefIndex++] = HeaderRef.Create("host", HeaderKind.Host, -1); - headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-content-sha256", HeaderKind.AmzSha256, -1); - headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-date", HeaderKind.AmzDate, -1); + headerRefs[hrefIndex++] = HeaderRef.Create(HeaderHost, HeaderKind.Host, -1); + headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzContentSha256, HeaderKind.AmzSha256, -1); + headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzDate, HeaderKind.AmzDate, -1); - if (hasApiVersion) headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-api-version", HeaderKind.AmzApiVersion, -1); - if (!string.IsNullOrWhiteSpace(credentials.SessionToken)) headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-security-token", HeaderKind.AmzSecurityToken, -1); - if (hasTarget) headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-target", HeaderKind.AmzTarget, -1); + if (hasApiVersion) headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzApiVersion, HeaderKind.AmzApiVersion, -1); + if (!string.IsNullOrWhiteSpace(credentials.SessionToken)) headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzSecurityToken, HeaderKind.AmzSecurityToken, -1); + if (hasTarget) headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzTarget, HeaderKind.AmzTarget, -1); // Add request headers from cached array, skipping AWS-managed headers var filteredIndex = 0; @@ -1101,12 +1107,12 @@ private static string BuildSignedHeadersFromRefs(HeaderRef[] headerRefs, int cou [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsAwsManagedHeader(string headerName) { - return string.Equals(headerName, "Host", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-content-sha256", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-date", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-target", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-api-version", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-security-token", StringComparison.OrdinalIgnoreCase); + return string.Equals(headerName, HeaderHost, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzContentSha256, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzDate, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzTarget, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzApiVersion, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzSecurityToken, StringComparison.OrdinalIgnoreCase); } /// From d614c879804240a90fed6a7980e09fffe83f1b90 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 18:53:41 +0000 Subject: [PATCH 24/34] perf: Guard amzHeaders Dictionary and BeginScope with log-level check --- .../Goa.Clients.Core/AwsServiceClient.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index cb7f4f1a..579b7494 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -108,16 +108,22 @@ protected async Task SendAsync(HttpRequestMessage request, : null; // Log x-amz response headers separately with capacity hint - Dictionary? amzHeaders = null; - foreach (var header in response.Headers) + IDisposable? amzLogContext = null; + if (Logger.IsEnabled(Configuration.LogLevel)) { - if (header.Key.StartsWith("x-amz", StringComparison.OrdinalIgnoreCase)) + Dictionary? amzHeaders = null; + foreach (var header in response.Headers) { - amzHeaders ??= new Dictionary(4); - amzHeaders[header.Key] = string.Join(", ", header.Value); + if (header.Key.StartsWith("x-amz", StringComparison.OrdinalIgnoreCase)) + { + amzHeaders ??= new Dictionary(4); + amzHeaders[header.Key] = string.Join(", ", header.Value); + } } + if (amzHeaders is not null) + amzLogContext = Logger.BeginScope(amzHeaders); } - using var amzLogContext = amzHeaders is not null ? Logger.BeginScope(amzHeaders) : null; + using var _ = amzLogContext; Logger.RequestComplete(Configuration.LogLevel, Stopwatch.GetElapsedTime(start).TotalMilliseconds); From eba58c2245d305eb865f8ffa1b3b8d4a5f0883ee Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 18:53:47 +0000 Subject: [PATCH 25/34] perf: Replace FirstOrDefault with manual enumeration in ResponseHeaders --- src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs b/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs index 497315be..c4d9e559 100644 --- a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs +++ b/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs @@ -41,19 +41,19 @@ internal static ResponseHeaders FromHttpResponse(HttpResponseHeaders headers, Ht if (headers.TryGetValues(AmznRequestId, out var values) || headers.TryGetValues(AmzRequestId, out values)) { - requestId = values.FirstOrDefault(); + foreach (var v in values) { requestId = v; break; } } string? errorType = null; if (headers.TryGetValues(AmznErrorType, out var typeValues)) { - errorType = typeValues.FirstOrDefault(); + foreach (var v in typeValues) { errorType = v; break; } } string? errorMessage = null; if (headers.TryGetValues(AmznErrorMessage, out var errorValues)) { - errorMessage = errorValues.FirstOrDefault(); + foreach (var v in errorValues) { errorMessage = v; break; } } return new ResponseHeaders From a1796ba2114aa4cc1e1d69cf1877939223f3f9fc Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 18:54:10 +0000 Subject: [PATCH 26/34] perf: Use span-based TrimStart in IsJsonSerialized and IsXmlSerialized --- src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs | 4 ++-- src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs index ebd71225..f32823cf 100644 --- a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs @@ -152,7 +152,7 @@ protected async Task> ProcessJsonResponseAsync /// private static bool IsJsonSerialized(string input) { - var trimmed = input.TrimStart(); - return trimmed.StartsWith('{') || trimmed.StartsWith('['); + var trimmed = input.AsSpan().TrimStart(); + return trimmed.StartsWith("{") || trimmed.StartsWith("["); } } diff --git a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs index 294a37d6..a2b85d51 100644 --- a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs @@ -162,6 +162,6 @@ private static byte[] SerializeToXmlBytes(object request) /// private static bool IsXmlSerialized(string input) { - return input.TrimStart().StartsWith('<'); + return input.AsSpan().TrimStart().StartsWith("<"); } } \ No newline at end of file From 234fc2543e7fee6c94c4459a326cd39b88a991a6 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Fri, 13 Mar 2026 10:10:43 +0000 Subject: [PATCH 27/34] perf: Remove ResponseHeaders class and use NonValidated header enumeration --- .../BedrockServiceClient.cs | 2 +- .../Goa.Clients.Core/AwsServiceClient.cs | 22 +++--- .../Goa.Clients.Core/Http/ApiResponse.cs | 12 ++-- .../Goa.Clients.Core/Http/RequestSigner.cs | 23 ++++--- .../Goa.Clients.Core/Http/ResponseHeaders.cs | 67 ------------------- .../Goa.Clients.Core/JsonAwsServiceClient.cs | 8 +-- .../Goa.Clients.Core/XmlAwsServiceClient.cs | 10 +-- .../Goa.Clients.Lambda/LambdaServiceClient.cs | 2 +- .../Goa.Clients.Sns/SnsServiceClient.cs | 6 +- 9 files changed, 43 insertions(+), 109 deletions(-) delete mode 100644 src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs diff --git a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs index 3fdc6748..7a55300a 100644 --- a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs +++ b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs @@ -111,7 +111,7 @@ public async Task> InvokeModelAsync(InvokeModelRequ return new InvokeModelResponse { Body = response.Value ?? string.Empty, - ContentType = response.Headers?.ContentType + ContentType = response.ContentType }; } diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index 579b7494..ea48fb86 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -102,8 +102,8 @@ protected async Task SendAsync(HttpRequestMessage request, // Log fixed response fields with zero-allocation scope using var responseLogContext = Logger.IsEnabled(Configuration.LogLevel) ? Logger.BeginScope(new LogScope2( - new(LogKeyStatusCode, GetStatusCodeString((int)response.StatusCode)), - new(LogKeyReasonPhrase, response.ReasonPhrase ?? response.StatusCode.ToString()) + new(LogKeyStatusCode, GetStatusCodeString(response.StatusCode)), + new(LogKeyReasonPhrase, response.ReasonPhrase ?? GetStatusCodeString(response.StatusCode)) )) : null; @@ -112,12 +112,14 @@ protected async Task SendAsync(HttpRequestMessage request, if (Logger.IsEnabled(Configuration.LogLevel)) { Dictionary? amzHeaders = null; - foreach (var header in response.Headers) + foreach (var header in response.Headers.NonValidated) { if (header.Key.StartsWith("x-amz", StringComparison.OrdinalIgnoreCase)) { amzHeaders ??= new Dictionary(4); +#pragma warning disable GOA1501 // Boxing HeaderStringValues to object is required for ILogger.BeginScope amzHeaders[header.Key] = string.Join(", ", header.Value); +#pragma warning restore GOA1501 } } if (amzHeaders is not null) @@ -166,14 +168,14 @@ protected static ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, A return error; } - private static string GetStatusCodeString(int statusCode) => statusCode switch + private static string GetStatusCodeString(System.Net.HttpStatusCode statusCode) => statusCode switch { - 200 => "200", 201 => "201", 202 => "202", 204 => "204", - 301 => "301", 302 => "302", 304 => "304", - 400 => "400", 401 => "401", 403 => "403", 404 => "404", - 409 => "409", 429 => "429", - 500 => "500", 502 => "502", 503 => "503", 504 => "504", - _ => statusCode.ToString() + System.Net.HttpStatusCode.OK => "200", System.Net.HttpStatusCode.Created => "201", System.Net.HttpStatusCode.Accepted => "202", System.Net.HttpStatusCode.NoContent => "204", + System.Net.HttpStatusCode.MovedPermanently => "301", System.Net.HttpStatusCode.Found => "302", System.Net.HttpStatusCode.NotModified => "304", + System.Net.HttpStatusCode.BadRequest => "400", System.Net.HttpStatusCode.Unauthorized => "401", System.Net.HttpStatusCode.Forbidden => "403", System.Net.HttpStatusCode.NotFound => "404", + System.Net.HttpStatusCode.Conflict => "409", System.Net.HttpStatusCode.TooManyRequests => "429", + System.Net.HttpStatusCode.InternalServerError => "500", System.Net.HttpStatusCode.BadGateway => "502", System.Net.HttpStatusCode.ServiceUnavailable => "503", System.Net.HttpStatusCode.GatewayTimeout => "504", + _ => ((int)statusCode).ToString() }; /// diff --git a/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs b/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs index 476101af..c1afc279 100644 --- a/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs +++ b/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs @@ -17,9 +17,9 @@ public sealed class ApiResponse public ApiError? Error { get; } /// - /// Gets the HTTP response headers. + /// Gets the Content-Type of the response body. /// - public ResponseHeaders? Headers { get; } + public string? ContentType { get; } /// /// Gets a value indicating whether the operation was successful. @@ -36,14 +36,14 @@ public ApiResponse(T? value) } /// - /// Initializes a new instance with a successful response value and headers. + /// Initializes a new instance with a successful response value and content type. /// /// The successful response value. - /// The HTTP response headers. - public ApiResponse(T? value, ResponseHeaders? headers) + /// The Content-Type of the response body. + public ApiResponse(T? value, string? contentType) { Value = value; - Headers = headers; + ContentType = contentType; } /// diff --git a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs index 3164a1a3..4ee4e666 100644 --- a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs +++ b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs @@ -1,6 +1,7 @@ using Goa.Clients.Core.Credentials; using System.Buffers; using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -235,11 +236,11 @@ private async ValueTask SignRequestWithCredentialsAsync( // ---- Build Canonical Request with precise memory management ---- // Enumerate headers ONCE into rented arrays to avoid repeated GetEnumeratorCore allocations const int InitialHeaderCapacity = 16; - var reqHeaders = ArrayPool>>.Shared.Rent(InitialHeaderCapacity); - var contentHeaders = ArrayPool>>.Shared.Rent(InitialHeaderCapacity); + var reqHeaders = ArrayPool>.Shared.Rent(InitialHeaderCapacity); + var contentHeaders = ArrayPool>.Shared.Rent(InitialHeaderCapacity); int rhIndex = 0, chIndex = 0; - foreach (var kv in request.Headers) + foreach (var kv in request.Headers.NonValidated) { if (rhIndex == reqHeaders.Length) reqHeaders = GrowArray(reqHeaders, ref rhIndex); @@ -247,7 +248,7 @@ private async ValueTask SignRequestWithCredentialsAsync( } if (request.Content?.Headers is not null) - foreach (var kv in request.Content.Headers) + foreach (var kv in request.Content.Headers.NonValidated) { if (chIndex == contentHeaders.Length) contentHeaders = GrowArray(contentHeaders, ref chIndex); @@ -467,8 +468,8 @@ private async ValueTask SignRequestWithCredentialsAsync( // Return rented arrays ArrayPool.Shared.Return(headerRefs); - ArrayPool>>.Shared.Return(reqHeaders); - ArrayPool>>.Shared.Return(contentHeaders); + ArrayPool>.Shared.Return(reqHeaders); + ArrayPool>.Shared.Return(contentHeaders); return (signature, signedHeaders); } @@ -670,8 +671,8 @@ private static int WriteHeaderValue( in HeaderRef r, HttpRequestMessage req, AwsCredentials creds, - KeyValuePair>[] reqHeaders, - KeyValuePair>[] contentHeaders, + KeyValuePair[] reqHeaders, + KeyValuePair[] contentHeaders, ReadOnlySpan longDate, ReadOnlySpan payloadHashBytes) { @@ -735,7 +736,7 @@ private static int WriteHost(Span dest, Uri u) /// Collapses whitespace and joins with commas as per HTTP/AWS specifications. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int WriteNormalizedJoined(Span dest, IEnumerable values) + private static int WriteNormalizedJoined(Span dest, HeaderStringValues values) { var pos = 0; var firstVal = true; foreach (var raw in values) @@ -762,8 +763,8 @@ private static int WriteNormalizedJoined(Span dest, IEnumerable va private static int EstimateHeadersCharCount( HeaderRef[] refs, int count, HttpRequestMessage req, AwsCredentials creds, - KeyValuePair>[] reqHeaders, - KeyValuePair>[] contentHeaders) + KeyValuePair[] reqHeaders, + KeyValuePair[] contentHeaders) { var total = 0; for (var i = 0; i < count; i++) diff --git a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs b/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs deleted file mode 100644 index c4d9e559..00000000 --- a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Net.Http.Headers; - -namespace Goa.Clients.Core.Http; - -/// -/// Lightweight representation of key AWS response headers. -/// -public sealed class ResponseHeaders -{ - private const string AmznRequestId = "x-amzn-RequestId"; - private const string AmzRequestId = "x-amz-request-id"; - private const string AmznErrorType = "x-amzn-ErrorType"; - private const string AmznErrorMessage = "x-amzn-ErrorMessage"; - - /// - /// Gets the AWS request ID from the response headers. - /// - public string? RequestId { get; init; } - - /// - /// Gets the error type from the response headers. - /// - public string? ErrorType { get; init; } - - /// - /// Gets the error message from the response headers. - /// - public string? ErrorMessage { get; init; } - - /// - /// Gets the Content-Type of the response body. - /// - public string? ContentType { get; init; } - - /// - /// Extracts key headers from an HTTP response. - /// - internal static ResponseHeaders FromHttpResponse(HttpResponseHeaders headers, HttpContentHeaders? contentHeaders = null) - { - string? requestId = null; - if (headers.TryGetValues(AmznRequestId, out var values) - || headers.TryGetValues(AmzRequestId, out values)) - { - foreach (var v in values) { requestId = v; break; } - } - - string? errorType = null; - if (headers.TryGetValues(AmznErrorType, out var typeValues)) - { - foreach (var v in typeValues) { errorType = v; break; } - } - - string? errorMessage = null; - if (headers.TryGetValues(AmznErrorMessage, out var errorValues)) - { - foreach (var v in errorValues) { errorMessage = v; break; } - } - - return new ResponseHeaders - { - RequestId = requestId, - ErrorType = errorType, - ErrorMessage = errorMessage, - ContentType = contentHeaders?.ContentType?.MediaType - }; - } -} diff --git a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs index f32823cf..715c1113 100644 --- a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs @@ -116,22 +116,22 @@ protected async Task> ProcessJsonResponseAsync return new ApiResponse(error); } - var headers = ResponseHeaders.FromHttpResponse(response.Headers, response.Content.Headers); + var contentType = response.Content.Headers.ContentType?.MediaType; using var pooledBuffer = await ReadResponseBytesAsync(response, cancellationToken); if (typeof(TResponse) == typeof(string)) { var str = pooledBuffer.Length > 0 ? Encoding.UTF8.GetString(pooledBuffer.Span) : string.Empty; - return new ApiResponse(str as TResponse, headers); + return new ApiResponse(str as TResponse, contentType); } if (pooledBuffer.Length == 0) - return new ApiResponse(default(TResponse), headers); + return new ApiResponse(default(TResponse), contentType); var typeInfo = ResolveJsonTypeInfo(); var jsonReader = new Utf8JsonReader(pooledBuffer.Span); var result = JsonSerializer.Deserialize(ref jsonReader, typeInfo); - return new ApiResponse(result, headers); + return new ApiResponse(result, contentType); } /// diff --git a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs index a2b85d51..b7c22940 100644 --- a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs @@ -70,8 +70,6 @@ protected async Task> SendAsync(Http private async Task> ProcessXmlResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where TResponse : class, IDeserializeFromXml, new() { - var headers = ResponseHeaders.FromHttpResponse(response.Headers); - if (!response.IsSuccessStatusCode) { using var errorBuffer = await ReadResponseBytesAsync(response, cancellationToken); @@ -110,17 +108,17 @@ private async Task> ProcessXmlResponseAsync(Ht // Handle string responses specially if (typeof(TResponse) == typeof(string)) { - return new ApiResponse(contentPayload as TResponse, headers); + return new ApiResponse(contentPayload as TResponse); } if (string.IsNullOrWhiteSpace(contentPayload)) { - return new ApiResponse(default(TResponse), headers); + return new ApiResponse(default(TResponse)); } var result = new TResponse(); result.DeserializeFromXml(contentPayload); - return new ApiResponse(result, headers); + return new ApiResponse(result); } /// @@ -152,7 +150,9 @@ private static byte[] SerializeToXmlBytes(object request) } catch (Exception ex) { +#pragma warning disable GOA1001 // Error-handling path: params allocation is acceptable here Logger.LogWarning(ex, "Failed to deserialize XML error response: {Content}", content); +#pragma warning restore GOA1001 return null; } } diff --git a/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs b/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs index bfc34192..8022312b 100644 --- a/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs +++ b/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs @@ -66,7 +66,7 @@ public async Task> InvokeSynchronousAsync(InvokeRequest return ConvertApiError(response.Error!); // TODO: Lambda-specific headers (X-Amz-Function-Error, X-Amz-Log-Result, X-Amz-Executed-Version) - // are not captured by ResponseHeaders. Will be addressed when Lambda invoke is refactored + // are not captured. Will be addressed when Lambda invoke is refactored // to read directly from HttpResponseMessage. var invokeResponse = InvokeResponse.FromHttpResponse( 200, diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index 2b49e677..d300475d 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -130,8 +130,6 @@ private static string SerializeToQueryParameters(PublishRequest request) private async Task> ProcessSnsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where TResponse : class, IDeserializeFromXml, new() { - var headers = ResponseHeaders.FromHttpResponse(response.Headers); - if (!response.IsSuccessStatusCode) { using var errorBuffer = await ReadResponseBytesAsync(response, cancellationToken); @@ -168,12 +166,12 @@ private async Task> ProcessSnsResponseAsync(Ht if (string.IsNullOrWhiteSpace(contentPayload)) { - return new ApiResponse(default(TResponse), headers); + return new ApiResponse(default(TResponse)); } var result = new TResponse(); result.DeserializeFromXml(contentPayload); - return new ApiResponse(result, headers); + return new ApiResponse(result); } /// From 9c13b92eb0e937fd02c1a57654e0b3e6a6060017 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Fri, 13 Mar 2026 10:11:02 +0000 Subject: [PATCH 28/34] perf: Adopt LoggerMessage source generator for service clients and core logging --- .../EventBridgeServiceClient.cs | 2 +- src/Clients/Goa.Clients.EventBridge/Log.cs | 9 ++++ .../Goa.Clients.Lambda/LambdaServiceClient.cs | 13 ++++-- src/Clients/Goa.Clients.Lambda/Log.cs | 15 +++++++ src/Clients/Goa.Clients.Sns/Log.cs | 12 ++++++ .../Goa.Clients.Sns/SnsServiceClient.cs | 4 +- src/Clients/Goa.Clients.Sqs/Log.cs | 18 ++++++++ .../Goa.Clients.Sqs/SqsServiceClient.cs | 8 ++-- .../Goa.Functions.Core/Logging/JsonLogger.cs | 4 +- src/Goa.Core/LoggerExtensions.cs | 41 +++++++++++++++---- 10 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 src/Clients/Goa.Clients.EventBridge/Log.cs create mode 100644 src/Clients/Goa.Clients.Lambda/Log.cs create mode 100644 src/Clients/Goa.Clients.Sns/Log.cs create mode 100644 src/Clients/Goa.Clients.Sqs/Log.cs diff --git a/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs b/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs index c7492305..ffffe185 100644 --- a/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs +++ b/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs @@ -47,7 +47,7 @@ public async Task> PutEventsAsync(PutEventsRequest re } catch (Exception ex) { - Logger.LogError(ex, "Failed to put events to EventBridge"); + Logger.PutEventsFailed(ex); return Error.Failure("EventBridge.PutEvents.Failed", "Failed to put events to EventBridge"); } } diff --git a/src/Clients/Goa.Clients.EventBridge/Log.cs b/src/Clients/Goa.Clients.EventBridge/Log.cs new file mode 100644 index 00000000..17e0cbf3 --- /dev/null +++ b/src/Clients/Goa.Clients.EventBridge/Log.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.EventBridge; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to put events to EventBridge")] + public static partial void PutEventsFailed(this ILogger logger, Exception exception); +} diff --git a/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs b/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs index 8022312b..6070892c 100644 --- a/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs +++ b/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs @@ -41,7 +41,12 @@ public async Task> InvokeSynchronousAsync(InvokeRequest // Add log type header if (request.LogType != LogType.None) - headers["X-Amz-Log-Type"] = request.LogType.ToString(); + headers["X-Amz-Log-Type"] = request.LogType switch + { + LogType.None => nameof(LogType.None), + LogType.Tail => nameof(LogType.Tail), + _ => throw new ArgumentOutOfRangeException(nameof(request.LogType), request.LogType, null) + }; // Add client context header if (!string.IsNullOrWhiteSpace(request.ClientContext)) @@ -77,7 +82,7 @@ public async Task> InvokeSynchronousAsync(InvokeRequest } catch (Exception ex) { - Logger.LogError(ex, "Failed to invoke Lambda function synchronously {FunctionName}", request.FunctionName); + Logger.InvokeSynchronousFailed(ex, request.FunctionName); return Error.Failure("Lambda.InvokeSynchronous.Failed", $"Failed to invoke Lambda function synchronously {request.FunctionName}"); } } @@ -125,7 +130,7 @@ public async Task> InvokeAsynchronousAsync(InvokeAs } catch (Exception ex) { - Logger.LogError(ex, "Failed to invoke Lambda function asynchronously {FunctionName}", request.FunctionName); + Logger.InvokeAsynchronousFailed(ex, request.FunctionName); return Error.Failure("Lambda.InvokeAsynchronous.Failed", $"Failed to invoke Lambda function asynchronously {request.FunctionName}"); } } @@ -173,7 +178,7 @@ public async Task> InvokeDryRunAsync(InvokeDryRunR } catch (Exception ex) { - Logger.LogError(ex, "Failed to dry run Lambda function {FunctionName}", request.FunctionName); + Logger.InvokeDryRunFailed(ex, request.FunctionName); return Error.Failure("Lambda.InvokeDryRun.Failed", $"Failed to dry run Lambda function {request.FunctionName}"); } } diff --git a/src/Clients/Goa.Clients.Lambda/Log.cs b/src/Clients/Goa.Clients.Lambda/Log.cs new file mode 100644 index 00000000..c60e667f --- /dev/null +++ b/src/Clients/Goa.Clients.Lambda/Log.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.Lambda; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to invoke Lambda function synchronously {FunctionName}")] + public static partial void InvokeSynchronousFailed(this ILogger logger, Exception exception, string functionName); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Failed to invoke Lambda function asynchronously {FunctionName}")] + public static partial void InvokeAsynchronousFailed(this ILogger logger, Exception exception, string functionName); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Failed to dry run Lambda function {FunctionName}")] + public static partial void InvokeDryRunFailed(this ILogger logger, Exception exception, string functionName); +} diff --git a/src/Clients/Goa.Clients.Sns/Log.cs b/src/Clients/Goa.Clients.Sns/Log.cs new file mode 100644 index 00000000..009ecfaa --- /dev/null +++ b/src/Clients/Goa.Clients.Sns/Log.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.Sns; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to publish message to SNS target {Target}")] + public static partial void PublishFailed(this ILogger logger, Exception exception, string? target); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Failed to deserialize SNS XML error response: {Content}")] + public static partial void DeserializeSnsErrorFailed(this ILogger logger, Exception exception, string content); +} diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index d300475d..e4ed858f 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -61,7 +61,7 @@ public async Task> PublishAsync(PublishRequest request, catch (Exception ex) { var target = request.TopicArn ?? request.TargetArn ?? request.PhoneNumber; - Logger.LogError(ex, "Failed to publish message to SNS target {Target}", target); + Logger.PublishFailed(ex, target); return Error.Failure("SNS.Publish.Failed", $"Failed to publish message to SNS target {target}"); } } @@ -190,7 +190,7 @@ private async Task> ProcessSnsResponseAsync(Ht } catch (Exception ex) { - Logger.LogWarning(ex, "Failed to deserialize SNS XML error response: {Content}", content); + Logger.DeserializeSnsErrorFailed(ex, content); return null; } } diff --git a/src/Clients/Goa.Clients.Sqs/Log.cs b/src/Clients/Goa.Clients.Sqs/Log.cs new file mode 100644 index 00000000..3e7f6ecd --- /dev/null +++ b/src/Clients/Goa.Clients.Sqs/Log.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.Sqs; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to send message to SQS queue {QueueUrl}")] + public static partial void SendMessageFailed(this ILogger logger, Exception exception, string queueUrl); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Failed to send message batch to SQS queue {QueueUrl}")] + public static partial void SendMessageBatchFailed(this ILogger logger, Exception exception, string queueUrl); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Failed to receive messages from SQS queue {QueueUrl}")] + public static partial void ReceiveMessageFailed(this ILogger logger, Exception exception, string queueUrl); + + [LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Failed to delete message from SQS queue {QueueUrl}")] + public static partial void DeleteMessageFailed(this ILogger logger, Exception exception, string queueUrl); +} diff --git a/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs b/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs index 385e6793..bbe16a5e 100644 --- a/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs @@ -50,7 +50,7 @@ public async Task> SendMessageAsync(SendMessageRequ } catch (Exception ex) { - Logger.LogError(ex, "Failed to send message to SQS queue {QueueUrl}", request.QueueUrl); + Logger.SendMessageFailed(ex, request.QueueUrl); return Error.Failure("SQS.SendMessage.Failed", $"Failed to send message to SQS queue {request.QueueUrl}"); } } @@ -90,7 +90,7 @@ public async Task> SendMessageBatchAsync(SendM } catch (Exception ex) { - Logger.LogError(ex, "Failed to send message batch to SQS queue {QueueUrl}", request.QueueUrl); + Logger.SendMessageBatchFailed(ex, request.QueueUrl); return Error.Failure("SQS.SendMessageBatch.Failed", $"Failed to send message batch to SQS queue {request.QueueUrl}"); } } @@ -118,7 +118,7 @@ public async Task> ReceiveMessageAsync(ReceiveMe } catch (Exception ex) { - Logger.LogError(ex, "Failed to receive messages from SQS queue {QueueUrl}", request.QueueUrl); + Logger.ReceiveMessageFailed(ex, request.QueueUrl); return Error.Failure("SQS.ReceiveMessage.Failed", $"Failed to receive messages from SQS queue {request.QueueUrl}"); } } @@ -146,7 +146,7 @@ public async Task> DeleteMessageAsync(DeleteMessa } catch (Exception ex) { - Logger.LogError(ex, "Failed to delete message from SQS queue {QueueUrl}", request.QueueUrl); + Logger.DeleteMessageFailed(ex, request.QueueUrl); return Error.Failure("SQS.DeleteMessage.Failed", $"Failed to delete message from SQS queue {request.QueueUrl}"); } } diff --git a/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs b/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs index 08eacdbf..868b5108 100644 --- a/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs +++ b/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs @@ -120,11 +120,11 @@ private static void WriteScopeInformation(Dictionary data, IExte } else if (scope is KeyValuePair kvp) { - data[kvp.Key] = kvp.Value; + state[kvp.Key] = kvp.Value; } else if (scope is not null) { - data[FieldState] = scope; + state[FieldState] = scope; } }, data); } diff --git a/src/Goa.Core/LoggerExtensions.cs b/src/Goa.Core/LoggerExtensions.cs index 8f2ef32a..f2094bef 100644 --- a/src/Goa.Core/LoggerExtensions.cs +++ b/src/Goa.Core/LoggerExtensions.cs @@ -5,7 +5,7 @@ namespace Goa.Core; /// /// Extends all ILogger instances with commonly used extension methods /// -public static class LoggerExtensions +public static partial class LoggerExtensions { /// /// Attaches a series of values to the context, using a Microsoft.Extensions.Logging compatible approach @@ -17,7 +17,15 @@ public static class LoggerExtensions if (values.Length == 0) return null; - return logger.BeginScope(values.ToDictionary(x => x.Key, x => (object)x.Value!)); + var dict = new Dictionary(values.Length); + foreach (var kv in values) + { + // Boxing T→object is unavoidable: ILogger.BeginScope requires IDictionary +#pragma warning disable GOA1501 + dict[kv.Key] = kv.Value!; +#pragma warning restore GOA1501 + } + return logger.BeginScope(dict); } /// @@ -30,7 +38,15 @@ public static class LoggerExtensions if (values.Length == 0) return null; - return logger.BeginScope(values.ToDictionary(x => x.key, x => (object)x.value!)); + var dict = new Dictionary(values.Length); + foreach (var (key, value) in values) + { + // Boxing T→object is unavoidable: ILogger.BeginScope requires IDictionary +#pragma warning disable GOA1501 + dict[key] = value!; +#pragma warning restore GOA1501 + } + return logger.BeginScope(dict); } /// @@ -44,12 +60,21 @@ public static class LoggerExtensions if (string.IsNullOrWhiteSpace(key)) return null; + // Boxing T→object is unavoidable: ILogger.BeginScope requires IDictionary +#pragma warning disable GOA1501 return logger.BeginScope(new Dictionary { [key] = value! }); +#pragma warning restore GOA1501 } + [LoggerMessage(EventId = 0, Level = LogLevel.Critical, Message = "{ExceptionMessage}")] + private static partial void LogExceptionCritical(this ILogger logger, string? exceptionMessage); + + [LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "{ExceptionMessage}")] + private static partial void LogExceptionError(this ILogger logger, string? exceptionMessage); + /// /// Helper to log an exception when there is no specific message associated with it /// @@ -59,12 +84,12 @@ public static class LoggerExtensions /// The type of the exception thrown public static void LogException(this ILogger logger, T exception, bool critical = false) where T : Exception { - using var scope = logger.WithContext(new [] { ("Exception:Type", exception.GetType().FullName ), ("Exception:StackTrace", exception.StackTrace ) }); -#pragma warning disable CA2254 +#pragma warning disable GOA1401 // Explicit array required for params overload + using var scope = logger.WithContext(new KeyValuePair[] { new("Exception:Type", exception.GetType().FullName), new("Exception:StackTrace", exception.StackTrace) }); +#pragma warning restore GOA1401 if (critical) - logger.LogCritical(exception.Message); + logger.LogExceptionCritical(exception.Message); else - logger.LogError(exception.Message); -#pragma warning restore CA2254 + logger.LogExceptionError(exception.Message); } } From 13c33482ea6fd9234b21dd6352c271d483fb17c4 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Fri, 13 Mar 2026 10:11:20 +0000 Subject: [PATCH 29/34] perf: Replace LINQ with manual loops to reduce allocations --- .../DynamoConversationStore.cs | 30 ++-- .../Chat/ChatSession.cs | 70 +++++---- .../Errors/ErrorExtensions.cs | 49 +++++-- .../Errors/ErrorExtensions.cs | 136 +++++++++++------- .../Extensions/DynamoRecordExtensions.cs | 42 ++++-- .../Batch/BatchWriteTableBuilder.cs | 4 + .../Operations/Condition.cs | 57 ++++++-- .../Operations/GetItem/GetItemBuilder.cs | 2 +- .../Operations/Query/QueryBuilder.cs | 2 +- .../Operations/Scan/ScanBuilder.cs | 2 +- .../UpdateItem/UpdateItemBuilder.cs | 17 ++- .../Operations/Invoke/InvokeResponse.cs | 6 +- .../Features/V1/LambdaHttpRequestFeatureV1.cs | 14 +- .../V2/LambdaHttpAuthenticationFeatureV2.cs | 35 +++-- .../Bootstrapping/LambdaRuntimeClient.cs | 9 +- src/Goa.Core/CollectionExtensions.cs | 4 +- 16 files changed, 319 insertions(+), 160 deletions(-) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs index 32256bed..dda4583f 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs @@ -212,10 +212,10 @@ public Task>> AddMessagesAsync( IEnumerable<(ConversationRole Role, Message Message, TokenUsage? TokenUsage)> messages, CancellationToken ct) { - return AddMessagesInternalAsync( - conversationId, - messages.Select(m => (m.Role, m.Message, m.TokenUsage, (IReadOnlyDictionary>?)null)), - ct); + var expanded = new List<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary>? ExtractedTags)>(); + foreach (var m in messages) + expanded.Add((m.Role, m.Message, m.TokenUsage, null)); + return AddMessagesInternalAsync(conversationId, expanded, ct); } private async Task>> AddMessagesInternalAsync( @@ -223,7 +223,7 @@ private async Task>> AddMessagesInter IEnumerable<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary>? ExtractedTags)> messages, CancellationToken ct) { - var messageList = messages.ToList(); + var messageList = new List<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary>? ExtractedTags)>(messages); if (messageList.Count == 0) return Error.Validation(ConversationErrorCodes.MessagesEmpty, "At least one message must be provided"); @@ -503,15 +503,19 @@ public async Task> DeleteConversationAsync(string conversationI const int batchSize = 25; for (var i = 0; i < allItems.Count; i += batchSize) { - var batch = allItems.Skip(i).Take(batchSize).ToList(); - var transactItems = batch.Select(key => new TransactWriteItem + var end = Math.Min(i + batchSize, allItems.Count); + var transactItems = new List(end - i); + for (var j = i; j < end; j++) { - Delete = new TransactDeleteItem + transactItems.Add(new TransactWriteItem { - TableName = _configuration.TableName, - Key = key - } - }).ToList(); + Delete = new TransactDeleteItem + { + TableName = _configuration.TableName, + Key = allItems[j] + } + }); + } var transactRequest = new TransactWriteRequest { @@ -607,7 +611,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary< if (record.TryGetStringSet(TagsAttribute, out var tags)) { - metadata.Tags = tags.ToList(); + metadata.Tags = new List(tags); hasMetadata = true; } diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs index bb870330..d31fa971 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs @@ -88,7 +88,7 @@ public async Task> SendAsync(IEnumerable con var userMessage = new Message { Role = ConversationRole.User, - Content = content.ToList() + Content = new List(content) }; // Clean message content (extract XML tags) @@ -163,24 +163,32 @@ public async Task> SendAsync(IEnumerable con } // Execute tools - var toolUseBlocks = assistantMessage.Content - .Where(c => c.ToolUse != null) - .Select(c => c.ToolUse!) - .ToList(); - var toolResults = new List(); - foreach (var toolUse in toolUseBlocks) + foreach (var block in assistantMessage.Content) { + var toolUse = block.ToolUse; + if (toolUse is null) continue; + var toolResult = await _toolAdapter.ExecuteToolAsync(toolUse, ct).ConfigureAwait(false); toolResults.Add(new ContentBlock { ToolResult = toolResult }); + string resultText = string.Empty; + foreach (var resultContent in toolResult.Content) + { + if (resultContent.Text is not null) + { + resultText = resultContent.Text; + break; + } + } + // Track tool execution toolExecutions.Add(new ToolExecution { ToolName = toolUse.Name, ToolUseId = toolUse.ToolUseId, Input = JsonDocument.Parse(toolUse.Input.GetRawText()), - Result = toolResult.Content.FirstOrDefault()?.Text ?? string.Empty, + Result = resultText, Success = toolResult.Status == "success" }); } @@ -218,10 +226,15 @@ public async Task> SendAsync(IEnumerable con var (cleanedFinalMessage, finalExtractedTags) = CleanMessageContent(finalMessage); // Extract cleaned text content - var cleanedText = cleanedFinalMessage.Content - .Where(c => c.Text != null) - .Select(c => c.Text!) - .FirstOrDefault() ?? string.Empty; + var cleanedText = string.Empty; + foreach (var block in cleanedFinalMessage.Content) + { + if (block.Text is not null) + { + cleanedText = block.Text; + break; + } + } // Detect thinking-only response: tags were extracted but no real text remains. // Note: CleanMessageContent preserves original content when all text blocks are @@ -305,13 +318,17 @@ public async Task> SendAsync(IEnumerable con /// public Task>> GetHistoryAsync(CancellationToken ct = default) { - var history = _messages.Select((m, index) => new ChatMessage + var history = new List(_messages.Count); + foreach (var m in _messages) { - Role = m.Role, - Content = m.Content, - Timestamp = DateTimeOffset.UtcNow, // In-memory messages don't have timestamps - TokenUsage = null - }).ToList(); + history.Add(new ChatMessage + { + Role = m.Role, + Content = m.Content, + Timestamp = DateTimeOffset.UtcNow, // In-memory messages don't have timestamps + TokenUsage = null + }); + } return Task.FromResult>>(history); } @@ -416,14 +433,16 @@ private static (Message CleanedMessage, IReadOnlyDictionary kvp.Key, - kvp => (IReadOnlyList)kvp.Value); + var result = new Dictionary>(allTags.Count); + foreach (var kvp in allTags) + { + result[kvp.Key] = kvp.Value; + } if (cleanedContent.Count == 0 && message.Content.Count > 0) { // Tag extraction emptied all content - preserve original to avoid empty messages - return (new Message { Role = message.Role, Content = message.Content.ToList() }, result); + return (new Message { Role = message.Role, Content = new List(message.Content) }, result); } return (new Message { Role = message.Role, Content = cleanedContent }, result); @@ -452,7 +471,10 @@ private ConverseRequest BuildConverseRequest(IReadOnlyList? bedrockTools) if (_options.StopSequences is { Count: > 0 }) { - builder.WithStopSequences(_options.StopSequences.ToArray()); + if (_options.StopSequences is string[] arr) + builder.WithStopSequences(arr); + else + builder.WithStopSequences(_options.StopSequences.ToArray()); } if (_options.ServiceTier.HasValue) @@ -471,7 +493,7 @@ private ConverseRequest BuildConverseRequest(IReadOnlyList? bedrockTools) // Add tools if any are registered if (bedrockTools is { Count: > 0 }) { - request.ToolConfig = new ToolConfiguration { Tools = bedrockTools.ToList() }; + request.ToolConfig = new ToolConfiguration { Tools = new List(bedrockTools) }; } if (_options.OutputConfig is not null) diff --git a/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs b/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs index 2ed6c7cb..7fdb32b2 100644 --- a/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs +++ b/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs @@ -16,7 +16,12 @@ public static class ErrorExtensions public static bool IsBedrockError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => e.Code.StartsWith("Goa.Bedrock.", StringComparison.Ordinal)); + foreach (var e in errorOr.Errors) + { + if (e.Code.StartsWith("Goa.Bedrock.", StringComparison.Ordinal)) + return true; + } + return false; } /// @@ -28,9 +33,13 @@ public static bool IsBedrockError(this ErrorOr errorOr) public static bool IsBedrockThrottlingError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == BedrockErrorCodes.ThrottlingException || - e.Code == BedrockErrorCodes.ServiceQuotaExceededException); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ThrottlingException || + e.Code == BedrockErrorCodes.ServiceQuotaExceededException) + return true; + } + return false; } /// @@ -42,9 +51,13 @@ public static bool IsBedrockThrottlingError(this ErrorOr errorOr) public static bool IsBedrockValidationError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == BedrockErrorCodes.ValidationException || - e.Type == ErrorType.Validation); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ValidationException || + e.Type == ErrorType.Validation) + return true; + } + return false; } /// @@ -56,10 +69,14 @@ public static bool IsBedrockValidationError(this ErrorOr errorOr) public static bool IsBedrockModelError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == BedrockErrorCodes.ModelErrorException || - e.Code == BedrockErrorCodes.ModelNotReadyException || - e.Code == BedrockErrorCodes.ModelTimeoutException); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ModelErrorException || + e.Code == BedrockErrorCodes.ModelNotReadyException || + e.Code == BedrockErrorCodes.ModelTimeoutException) + return true; + } + return false; } /// @@ -72,7 +89,13 @@ public static bool IsBedrockModelError(this ErrorOr errorOr) public static bool IsBedrockRetryableError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.IsBedrockThrottlingError() || - errorOr.Errors.Any(e => e.Code == BedrockErrorCodes.ModelTimeoutException); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ThrottlingException || + e.Code == BedrockErrorCodes.ServiceQuotaExceededException || + e.Code == BedrockErrorCodes.ModelTimeoutException) + return true; + } + return false; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs b/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs index 6625920c..1a7064ac 100644 --- a/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs @@ -16,7 +16,12 @@ public static class ErrorExtensions public static bool IsDynamoError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => e.Code.StartsWith("Goa.DynamoDb.", StringComparison.Ordinal)); + foreach (var e in errorOr.Errors) + { + if (e.Code.StartsWith("Goa.DynamoDb.", StringComparison.Ordinal)) + return true; + } + return false; } /// @@ -28,13 +33,17 @@ public static bool IsDynamoError(this ErrorOr errorOr) public static bool IsDynamoConcurrencyError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ConditionalCheckFailedException || - e.Code == DynamoErrorCodes.ConditionalCheckFailed || - e.Code == DynamoErrorCodes.TransactionConflictException || - e.Code == DynamoErrorCodes.TransactionConflict || - e.Code == DynamoErrorCodes.ReplicatedWriteConflictException || - (e.Code == DynamoErrorCodes.ValidationException && e.Description.Contains("condition", StringComparison.OrdinalIgnoreCase))); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ConditionalCheckFailedException || + e.Code == DynamoErrorCodes.ConditionalCheckFailed || + e.Code == DynamoErrorCodes.TransactionConflictException || + e.Code == DynamoErrorCodes.TransactionConflict || + e.Code == DynamoErrorCodes.ReplicatedWriteConflictException || + (e.Code == DynamoErrorCodes.ValidationException && e.Description.Contains("condition", StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; } /// @@ -46,15 +55,19 @@ public static bool IsDynamoConcurrencyError(this ErrorOr errorOr) public static bool IsDynamoItemNotFoundError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ResourceNotFoundException || - e.Code == DynamoErrorCodes.ItemNotFound || - e.Code == DynamoErrorCodes.NotFound || - e.Code == DynamoErrorCodes.TableNotFoundException || - e.Code == DynamoErrorCodes.TableNotFound || - e.Code == DynamoErrorCodes.IndexNotFoundException || - e.Code == DynamoErrorCodes.IndexNotFound || - e.Type == ErrorType.NotFound); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ResourceNotFoundException || + e.Code == DynamoErrorCodes.ItemNotFound || + e.Code == DynamoErrorCodes.NotFound || + e.Code == DynamoErrorCodes.TableNotFoundException || + e.Code == DynamoErrorCodes.TableNotFound || + e.Code == DynamoErrorCodes.IndexNotFoundException || + e.Code == DynamoErrorCodes.IndexNotFound || + e.Type == ErrorType.NotFound) + return true; + } + return false; } /// @@ -66,13 +79,17 @@ public static bool IsDynamoItemNotFoundError(this ErrorOr errorOr) public static bool IsDynamoThrottlingError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ProvisionedThroughputExceededException || - e.Code == DynamoErrorCodes.ProvisionedThroughputExceeded || - e.Code == DynamoErrorCodes.RequestLimitExceeded || - e.Code == DynamoErrorCodes.ThrottlingException || - e.Code == DynamoErrorCodes.TooManyRequestsException || - e.Code == DynamoErrorCodes.TooManyRequests); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ProvisionedThroughputExceededException || + e.Code == DynamoErrorCodes.ProvisionedThroughputExceeded || + e.Code == DynamoErrorCodes.RequestLimitExceeded || + e.Code == DynamoErrorCodes.ThrottlingException || + e.Code == DynamoErrorCodes.TooManyRequestsException || + e.Code == DynamoErrorCodes.TooManyRequests) + return true; + } + return false; } /// @@ -84,13 +101,17 @@ public static bool IsDynamoThrottlingError(this ErrorOr errorOr) public static bool IsDynamoValidationError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ValidationException || - e.Code == DynamoErrorCodes.InvalidParameterValueException || - e.Code == DynamoErrorCodes.InvalidParameterValue || - e.Code == DynamoErrorCodes.MissingParameterException || - e.Code == DynamoErrorCodes.MissingParameter || - e.Type == ErrorType.Validation); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ValidationException || + e.Code == DynamoErrorCodes.InvalidParameterValueException || + e.Code == DynamoErrorCodes.InvalidParameterValue || + e.Code == DynamoErrorCodes.MissingParameterException || + e.Code == DynamoErrorCodes.MissingParameter || + e.Type == ErrorType.Validation) + return true; + } + return false; } /// @@ -102,16 +123,20 @@ public static bool IsDynamoValidationError(this ErrorOr errorOr) public static bool IsDynamoAuthError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.UnauthorizedException || - e.Code == DynamoErrorCodes.Unauthorized || - e.Code == DynamoErrorCodes.AccessDeniedException || - e.Code == DynamoErrorCodes.AccessDenied || - e.Code == DynamoErrorCodes.InvalidUserPoolConfigurationException || - e.Code == DynamoErrorCodes.InvalidUserPoolConfiguration || - e.Code == DynamoErrorCodes.NotAuthorizedException || - e.Code == DynamoErrorCodes.NotAuthorized || - e.Type == ErrorType.Unauthorized); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.UnauthorizedException || + e.Code == DynamoErrorCodes.Unauthorized || + e.Code == DynamoErrorCodes.AccessDeniedException || + e.Code == DynamoErrorCodes.AccessDenied || + e.Code == DynamoErrorCodes.InvalidUserPoolConfigurationException || + e.Code == DynamoErrorCodes.InvalidUserPoolConfiguration || + e.Code == DynamoErrorCodes.NotAuthorizedException || + e.Code == DynamoErrorCodes.NotAuthorized || + e.Type == ErrorType.Unauthorized) + return true; + } + return false; } /// @@ -123,13 +148,18 @@ public static bool IsDynamoAuthError(this ErrorOr errorOr) public static bool IsDynamoRetryableError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.IsDynamoThrottlingError() || - errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.InternalServerError || - e.Code == DynamoErrorCodes.ServiceUnavailable || - e.Code == DynamoErrorCodes.RequestTimeoutException || - e.Code == DynamoErrorCodes.RequestTimeout || - (e.Type == ErrorType.Failure && !e.Description.Contains("permanent", StringComparison.OrdinalIgnoreCase))); + if (errorOr.IsDynamoThrottlingError()) + return true; + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.InternalServerError || + e.Code == DynamoErrorCodes.ServiceUnavailable || + e.Code == DynamoErrorCodes.RequestTimeoutException || + e.Code == DynamoErrorCodes.RequestTimeout || + (e.Type == ErrorType.Failure && !e.Description.Contains("permanent", StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; } /// @@ -141,9 +171,13 @@ public static bool IsDynamoRetryableError(this ErrorOr errorOr) public static bool IsDynamoResourceStateError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ResourceInUseException || - e.Code == DynamoErrorCodes.ResourceInUse); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ResourceInUseException || + e.Code == DynamoErrorCodes.ResourceInUse) + return true; + } + return false; } /// diff --git a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs index 7202c357..54a86d07 100644 --- a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs @@ -541,9 +541,11 @@ public static bool TryGetIntSet(this DynamoRecord record, string columnName, out if (attributeValue.NS == null) return false; - value = attributeValue.NS.Select(x => int.TryParse(x, out var val) ? val : (int?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.NS) + if (int.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } @@ -569,9 +571,11 @@ public static bool TryGetLongSet(this DynamoRecord record, string columnName, ou if (attributeValue.NS == null) return false; - value = attributeValue.NS.Select(x => long.TryParse(x, out var val) ? val : (long?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.NS) + if (long.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } @@ -597,9 +601,11 @@ public static bool TryGetDoubleSet(this DynamoRecord record, string columnName, if (attributeValue.NS == null) return false; - value = attributeValue.NS.Select(x => double.TryParse(x, out var val) ? val : (double?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.NS) + if (double.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } @@ -626,9 +632,13 @@ public static bool TryGetEnumSet(this DynamoRecord record, string columnName, if (attributeValue.SS == null) return false; - value = attributeValue.SS.Select(x => Enum.TryParse(x, out var val) ? val : (T?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.SS) + { + if (Enum.TryParse(x, out var val)) + result.Add(val); + } + value = result; return true; } @@ -1046,9 +1056,11 @@ public static bool TryGetDateTimeSet(this DynamoRecord record, string columnName if (attributeValue.SS == null) return false; - value = attributeValue.SS.Select(x => DateTime.TryParse(x, out var val) ? val : (DateTime?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.SS) + if (DateTime.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs index 19a891ea..70973913 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs @@ -30,7 +30,9 @@ public BatchWriteTableBuilder WithDelete(Dictionary key) /// The BatchWriteTableBuilder instance for method chaining. public BatchWriteTableBuilder WithDelete(IEnumerable> keys) { +#pragma warning disable GOA1301 // Enumerator allocation unavoidable - IEnumerable is public API parameter type foreach (var key in keys) +#pragma warning restore GOA1301 { WithDelete(key); } @@ -58,7 +60,9 @@ public BatchWriteTableBuilder WithPut(Dictionary item) /// The BatchWriteTableBuilder instance for method chaining. public BatchWriteTableBuilder WithPut(IEnumerable> items) { +#pragma warning disable GOA1301 // Enumerator allocation unavoidable - IEnumerable is public API parameter type foreach (var item in items) +#pragma warning restore GOA1301 { WithPut(item); } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs index 0a104461..a796f373 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs @@ -41,8 +41,14 @@ public Condition(string expression) public Condition(string expression, IEnumerable> expressionNames, IEnumerable> expressionValues) { Expression = expression; - ExpressionNames = expressionNames.ToDictionary(); - ExpressionValues = expressionValues.ToDictionary(); + var names = new Dictionary(); + foreach (var kvp in expressionNames) + names[kvp.Key] = kvp.Value; + ExpressionNames = names; + var values = new Dictionary(); + foreach (var kvp in expressionValues) + values[kvp.Key] = kvp.Value; + ExpressionValues = values; } /// @@ -294,10 +300,19 @@ public static Condition And(Condition left, Condition right) /// A condition representing the logical AND of all conditions. public static Condition And(params Condition[] conditions) { - var expression = string.Join(" AND ", conditions.Select(c => c.Expression)); - var expressionNames = conditions.SelectMany(c => c.ExpressionNames).ToDictionary(); - var expressionValues = conditions.SelectMany(c => c.ExpressionValues).ToDictionary(); - return new Condition(expression, expressionNames, expressionValues); + var sb = new System.Text.StringBuilder(); + var expressionNames = new Dictionary(); + var expressionValues = new Dictionary(); + for (var i = 0; i < conditions.Length; i++) + { + if (i > 0) sb.Append(" AND "); + sb.Append(conditions[i].Expression); + foreach (var kvp in conditions[i].ExpressionNames) + expressionNames[kvp.Key] = kvp.Value; + foreach (var kvp in conditions[i].ExpressionValues) + expressionValues[kvp.Key] = kvp.Value; + } + return new Condition(sb.ToString(), expressionNames, expressionValues); } /// @@ -321,10 +336,19 @@ public static Condition Or(Condition left, Condition right) /// A condition representing the logical OR of all conditions. public static Condition Or(params Condition[] conditions) { - var expression = string.Join(" OR ", conditions.Select(c => c.Expression)); - var expressionNames = conditions.SelectMany(c => c.ExpressionNames).ToDictionary(); - var expressionValues = conditions.SelectMany(c => c.ExpressionValues).ToDictionary(); - return new Condition(expression, expressionNames, expressionValues); + var sb = new System.Text.StringBuilder(); + var expressionNames = new Dictionary(); + var expressionValues = new Dictionary(); + for (var i = 0; i < conditions.Length; i++) + { + if (i > 0) sb.Append(" OR "); + sb.Append(conditions[i].Expression); + foreach (var kvp in conditions[i].ExpressionNames) + expressionNames[kvp.Key] = kvp.Value; + foreach (var kvp in conditions[i].ExpressionValues) + expressionValues[kvp.Key] = kvp.Value; + } + return new Condition(sb.ToString(), expressionNames, expressionValues); } /// @@ -338,10 +362,17 @@ public static Condition In(string attributeName, params AttributeValue[] values) if (values is null || values.Length == 0) throw new ArgumentException("At least one value must be provided for IN condition", nameof(values)); - var valueParams = values.Select((_, i) => $":{attributeName}{i}").ToArray(); +#pragma warning disable GOA1401 // Explicit arrays required for building expression parameters + var valueParams = new string[values.Length]; + var expressionValues = new KeyValuePair[values.Length]; + for (var i = 0; i < values.Length; i++) + { + valueParams[i] = $":{attributeName}{i}"; + expressionValues[i] = new KeyValuePair($":{attributeName}{i}", values[i]); + } var expression = $"#{attributeName} IN ({string.Join(", ", valueParams)})"; - var expressionNames = new[] { new KeyValuePair($"#{attributeName}", attributeName) }; - var expressionValues = values.Select((value, i) => new KeyValuePair($":{attributeName}{i}", value)); + var expressionNames = new KeyValuePair[] { new($"#{attributeName}", attributeName) }; +#pragma warning restore GOA1401 return new Condition(expression, expressionNames, expressionValues); } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs index 54a6d79c..70f7e959 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs @@ -64,7 +64,7 @@ public GetItemBuilder WithProjection(params string[] attributes) /// The GetItemBuilder instance for method chaining. public GetItemBuilder WithProjection(IEnumerable attributes) { - if (attributes?.Any() == true) + if (attributes is ICollection { Count: > 0 }) { _request.ProjectionExpression = string.Join(", ", attributes); } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs index 0a1e77b3..8f671826 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs @@ -149,7 +149,7 @@ public QueryBuilder WithProjection(params string[] attributes) /// The QueryBuilder instance for method chaining. public QueryBuilder WithProjection(IEnumerable attributes) { - if (attributes?.Any() == true) + if (attributes is ICollection { Count: > 0 }) { _request.ProjectionExpression = string.Join(", ", attributes); return WithSelectionMode(Select.SPECIFIC_ATTRIBUTES); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs index e0f1596f..67505944 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs @@ -103,7 +103,7 @@ public ScanBuilder WithProjection(params string[] attributes) /// The ScanBuilder instance for method chaining. public ScanBuilder WithProjection(IEnumerable attributes) { - if (attributes?.Any() == true) + if (attributes is ICollection { Count: > 0 }) { _request.ProjectionExpression = string.Join(", ", attributes); return WithSelectionMode(Select.SPECIFIC_ATTRIBUTES); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs index 76ae1e43..77e05ed4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs @@ -663,7 +663,7 @@ public UpdateItemBuilder AddToStringSet(string attributeName, params string[] va var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(values.ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(new List(values)); _addActions.Add($"{nameKey} {valueKey}"); } @@ -686,8 +686,11 @@ public UpdateItemBuilder AddToNumberSet(string attributeName, params decimal[] v var nameKey = $"#n{_request.ExpressionAttributeNames.Count}"; var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; + var numberStrings = new List(values.Length); + foreach (var v in values) + numberStrings.Add(v.ToString(CultureInfo.InvariantCulture)); _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(numberStrings); _addActions.Add($"{nameKey} {valueKey}"); } @@ -754,7 +757,7 @@ public UpdateItemBuilder RemoveFromStringSet(string attributeName, params string var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(values.ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(new List(values)); _deleteActions.Add($"{nameKey} {valueKey}"); } @@ -777,8 +780,11 @@ public UpdateItemBuilder RemoveFromNumberSet(string attributeName, params decima var nameKey = $"#n{_request.ExpressionAttributeNames.Count}"; var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; + var numberStrings = new List(values.Length); + foreach (var v in values) + numberStrings.Add(v.ToString(CultureInfo.InvariantCulture)); _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(numberStrings); _deleteActions.Add($"{nameKey} {valueKey}"); } @@ -919,8 +925,9 @@ private string BuildPathExpression(string path) var matches = PathSegmentRegex().Matches(path); var expressionParts = new List(); - foreach (Match match in matches) + for (var i = 0; i < matches.Count; i++) { + var match = matches[i]; if (match.Groups[1].Success) { // Attribute name diff --git a/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs b/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs index 9065ef7a..359cb0e3 100644 --- a/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs +++ b/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs @@ -57,11 +57,11 @@ public static InvokeResponse FromHttpResponse(int statusCode, string? payload, I if (headers != null) { if (headers.TryGetValue("X-Amz-Function-Error", out var functionError)) - response.FunctionError = functionError.FirstOrDefault(); + foreach (var v in functionError) { response.FunctionError = v; break; } if (headers.TryGetValue("X-Amz-Log-Result", out var logResult)) { - response.LogResult = logResult.FirstOrDefault(); + foreach (var v in logResult) { response.LogResult = v; break; } if (!string.IsNullOrWhiteSpace(response.LogResult)) { try @@ -76,7 +76,7 @@ public static InvokeResponse FromHttpResponse(int statusCode, string? payload, I } if (headers.TryGetValue("X-Amz-Executed-Version", out var executedVersion)) - response.ExecutedVersion = executedVersion.FirstOrDefault(); + foreach (var v in executedVersion) { response.ExecutedVersion = v; break; } } return response; diff --git a/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs b/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs index 876dd40b..1093fc35 100644 --- a/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs +++ b/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs @@ -29,7 +29,19 @@ public LambdaHttpRequestFeatureV1(ProxyPayloadV1Request request) private static string BuildQueryString(IDictionary? queryStringParameters) { if (queryStringParameters == null || queryStringParameters.Count == 0) return string.Empty; - return "?" + string.Join("&", queryStringParameters.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + + var sb = new StringBuilder("?"); + var first = true; + foreach (var kvp in queryStringParameters) + { + if (!first) + sb.Append('&'); + sb.Append(Uri.EscapeDataString(kvp.Key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(kvp.Value)); + first = false; + } + return sb.ToString(); } private static IHeaderDictionary BuildHeaderDictionary(IDictionary? headers, IDictionary>? multiValueHeaders) diff --git a/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs b/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs index 78633dd4..c4850c3a 100644 --- a/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs +++ b/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs @@ -30,11 +30,10 @@ public LambdaHttpAuthenticationFeatureV2(ProxyPayloadV2RequestAuthentication? au if (authorizer?.Jwt?.Claims != null) { - // Use the authorizer's JWT claims to build the ClaimsPrincipal - User = new ClaimsPrincipal(new ClaimsIdentity( - claims.Concat(authorizer.Jwt.Claims.Select(kvp => new Claim(kvp.Key, kvp.Value))), - "Jwt" - )); + foreach (var kvp in authorizer.Jwt.Claims) + claims.Add(new Claim(kvp.Key, kvp.Value)); + + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Jwt")); } else if (authorizer?.IAM is not null) { @@ -48,13 +47,10 @@ public LambdaHttpAuthenticationFeatureV2(ProxyPayloadV2RequestAuthentication? au } else if (authentication?.ClientCert != null) { - // Fallback to client certificate details - User = new ClaimsPrincipal(new ClaimsIdentity(claims.Concat(new[] - { - new Claim(ClaimTypes.Name, authentication.ClientCert.SubjectDN ?? "Anonymous"), - new Claim("Issuer", authentication.ClientCert.IssuerDN ?? string.Empty), - new Claim("SerialNumber", authentication.ClientCert.SerialNumber ?? string.Empty) - }), "ClientCert")); + claims.Add(new Claim(ClaimTypes.Name, authentication.ClientCert.SubjectDN ?? "Anonymous")); + claims.Add(new Claim("Issuer", authentication.ClientCert.IssuerDN ?? string.Empty)); + claims.Add(new Claim("SerialNumber", authentication.ClientCert.SerialNumber ?? string.Empty)); + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "ClientCert")); } else { @@ -74,11 +70,20 @@ private ClaimsPrincipal DecodeJwtToClaimsPrincipal(string jwt, List claim var token = handler.ReadJwtToken(jwt); - // Create claims from JWT payload - claims.AddRange(token.Claims.ToList()); + claims.AddRange(token.Claims); // Optionally add the token's subject as a Name claim if it exists - if (!string.IsNullOrEmpty(token.Subject) && claims.All(c => c.Type != ClaimTypes.NameIdentifier)) + var hasNameIdentifier = false; + foreach (var c in claims) + { + if (c.Type == ClaimTypes.NameIdentifier) + { + hasNameIdentifier = true; + break; + } + } + + if (!string.IsNullOrEmpty(token.Subject) && !hasNameIdentifier) { claims.Add(new Claim(ClaimTypes.NameIdentifier, token.Subject)); } diff --git a/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs b/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs index 52dd14f2..b5709b23 100644 --- a/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs +++ b/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs @@ -45,15 +45,18 @@ public async Task> GetNextInvocationAsync(Cancellation if (response.Headers.TryGetValues("Lambda-Runtime-Aws-Request-Id", out var requestIdValues)) { - requestId = requestIdValues.FirstOrDefault() ?? string.Empty; + requestId = string.Empty; + foreach (var v in requestIdValues) { requestId = v; break; } } if (response.Headers.TryGetValues("Lambda-Runtime-Deadline-Ms", out var deadlineMsValues)) { - deadlineMs = deadlineMsValues.FirstOrDefault() ?? string.Empty; + deadlineMs = string.Empty; + foreach (var v in deadlineMsValues) { deadlineMs = v; break; } } if (response.Headers.TryGetValues("Lambda-Runtime-Invoked-Function-Arn", out var functionArnValues)) { - functionArn = functionArnValues.FirstOrDefault() ?? string.Empty; + functionArn = string.Empty; + foreach (var v in functionArnValues) { functionArn = v; break; } } var payload = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/src/Goa.Core/CollectionExtensions.cs b/src/Goa.Core/CollectionExtensions.cs index bf2d48e6..7891eb50 100644 --- a/src/Goa.Core/CollectionExtensions.cs +++ b/src/Goa.Core/CollectionExtensions.cs @@ -22,7 +22,9 @@ public static IReadOnlyDictionary Merge { if (source is not Dictionary result) { - result = new Dictionary(source); + result = new Dictionary(source.Count); + foreach (var (key, value) in source) + result[key] = value; } foreach (var (key, value) in items) From 9e62f142d74190d558ffa3748765eee18d262bd9 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Fri, 13 Mar 2026 10:11:25 +0000 Subject: [PATCH 30/34] perf: Add specialized DynamoRecord response readers with property name caching --- .../Internal/DynamoResponseReader.cs | 223 ++++++++++++++++++ .../Internal/DynamoResponseReaderRegistry.cs | 41 +--- 2 files changed, 230 insertions(+), 34 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs index f5ab7b83..78cf8d56 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Goa.Clients.Dynamo.Models; using Goa.Clients.Dynamo.Operations.Batch; +using Goa.Clients.Dynamo.Operations.GetItem; using Goa.Clients.Dynamo.Operations.Query; using Goa.Clients.Dynamo.Operations.Scan; using Goa.Clients.Dynamo.Operations.Transactions; @@ -161,6 +162,228 @@ internal static DynamoRecord ReadDynamoRecordItemCached(ref Utf8JsonReader reade return record; } + internal static QueryResponse ReadDynamoRecordQueryResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new QueryResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Items"u8)) + { + reader.Read(); + result.Items = ReadDynamoRecordItems(ref reader, ref cache); + } + else if (reader.ValueTextEquals("Count"u8)) + { + reader.Read(); + // DynamoDB Count always equals Items.Count (post-filter); ScannedCount is the pre-filter total + reader.GetInt32(); + } + else if (reader.ValueTextEquals("ScannedCount"u8)) + { + reader.Read(); + result.ScannedCount = reader.GetInt32(); + } + else if (reader.ValueTextEquals("LastEvaluatedKey"u8)) + { + reader.Read(); + result.LastEvaluatedKey = ReadAttributeMap(ref reader); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static ScanResponse ReadDynamoRecordScanResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new ScanResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Items"u8)) + { + reader.Read(); + result.Items = ReadDynamoRecordItems(ref reader, ref cache); + } + else if (reader.ValueTextEquals("Count"u8)) + { + reader.Read(); + reader.GetInt32(); + } + else if (reader.ValueTextEquals("ScannedCount"u8)) + { + reader.Read(); + result.ScannedCount = reader.GetInt32(); + } + else if (reader.ValueTextEquals("LastEvaluatedKey"u8)) + { + reader.Read(); + result.LastEvaluatedKey = ReadAttributeMap(ref reader); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static GetItemResponse ReadDynamoRecordGetItemResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new GetItemResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Item"u8)) + { + reader.Read(); + result.Item = ReadDynamoRecordItemCached(ref reader, ref cache); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static BatchGetItemResponse ReadDynamoRecordBatchGetItemResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new BatchGetItemResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Responses"u8)) + { + reader.Read(); + result.Responses = ReadDynamoRecordTableResponses(ref reader, ref cache); + } + else if (reader.ValueTextEquals("UnprocessedKeys"u8)) + { + reader.Read(); + result.UnprocessedKeys = ReadUnprocessedKeys(ref reader); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static TransactGetItemResponse ReadDynamoRecordTransactGetItemResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new TransactGetItemResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Responses"u8)) + { + reader.Read(); + result.Responses = ReadDynamoRecordTransactGetResponses(ref reader, ref cache); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + private static List ReadDynamoRecordItems(ref Utf8JsonReader reader, ref PropertyNameCache cache) + { + var items = new List(); + if (reader.TokenType == JsonTokenType.Null) + return items; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + items.Add(ReadDynamoRecordItemCached(ref reader, ref cache)); + return items; + } + + private static Dictionary> ReadDynamoRecordTableResponses(ref Utf8JsonReader reader, ref PropertyNameCache cache) + { + var responses = new Dictionary>(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var tableName = reader.GetString()!; + reader.Read(); // StartArray + responses[tableName] = ReadDynamoRecordItems(ref reader, ref cache); + } + return responses; + } + + private static List ReadDynamoRecordTransactGetResponses(ref Utf8JsonReader reader, ref PropertyNameCache cache) + { + var items = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + DynamoRecord? item = null; + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.ValueTextEquals("Item"u8)) + { + reader.Read(); + item = ReadDynamoRecordItemCached(ref reader, ref cache); + } + else + { + reader.Read(); + reader.Skip(); + } + } + items.Add(new TransactGetResult { Item = item }); + } + return items; + } + private static List ReadItems(ref Utf8JsonReader reader, DynamoItemReader itemReader) { var items = new List(); diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs index a6097702..b03d2f22 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs @@ -30,6 +30,7 @@ private static class Cache static Cache() { +#pragma warning disable GOA1503 // One-time method group allocations cached in static readonly fields if (typeof(T) == typeof(QueryResponse)) Reader = (JsonReader)(object)(JsonReader)ReadQueryResponse; else if (typeof(T) == typeof(ScanResponse)) @@ -42,6 +43,7 @@ static Cache() Reader = (JsonReader)(object)(JsonReader)ReadTransactGetItemResponse; else Reader = FallbackReader; +#pragma warning restore GOA1503 } private static T FallbackReader(ref Utf8JsonReader reader) @@ -55,55 +57,26 @@ as System.Text.Json.Serialization.Metadata.JsonTypeInfo private static QueryResponse ReadQueryResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadQueryResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new QueryResponse - { - Items = result.Items, - LastEvaluatedKey = result.LastEvaluatedKey, - ScannedCount = result.ScannedCount, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordQueryResponse(ref reader); } private static ScanResponse ReadScanResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadScanResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new ScanResponse - { - Items = result.Items, - LastEvaluatedKey = result.LastEvaluatedKey, - ScannedCount = result.ScannedCount, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordScanResponse(ref reader); } private static GetItemResponse ReadGetItemResponse(ref Utf8JsonReader reader) { - var item = DynamoResponseReader.ReadGetItemResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new GetItemResponse { Item = item }; + return DynamoResponseReader.ReadDynamoRecordGetItemResponse(ref reader); } private static BatchGetItemResponse ReadBatchGetItemResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadBatchGetItemResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new BatchGetItemResponse - { - Responses = result.Responses, - UnprocessedKeys = result.UnprocessedKeys, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordBatchGetItemResponse(ref reader); } private static TransactGetItemResponse ReadTransactGetItemResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadTransactGetItemResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - var responses = new List(result.Items.Count); - foreach (var item in result.Items) - responses.Add(new TransactGetResult { Item = item }); - return new TransactGetItemResponse - { - Responses = responses, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordTransactGetItemResponse(ref reader); } } From 7a162a3f76c968a939ec852518b7f9e1570db2c2 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Fri, 13 Mar 2026 10:11:41 +0000 Subject: [PATCH 31/34] perf: Suppress boxing warning for error-path Dictionary in DynamoServiceClient --- src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index cb794b31..f5aaae78 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -366,7 +366,9 @@ private async Task HandleTypedErrorAsync(HttpResponseMessage response, Ca var metadata = new Dictionary { ["Payload"] = errorPayload, +#pragma warning disable GOA1501 // Boxing unavoidable with Dictionary on error path ["StatusCode"] = response.StatusCode +#pragma warning restore GOA1501 }; return Error.Failure( code: MapErrorCodeToDynamo(errorType), From c7549add77256d98aeb909e1db837a92a56a5bd0 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Tue, 17 Mar 2026 16:17:22 +0000 Subject: [PATCH 32/34] Minor updates --- .../DynamoMapperIncrementalGenerator.cs | 17 +++++++++-------- .../Goa.Clients.Dynamo/Operations/Condition.cs | 12 ++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs index 1b33432a..76f79e01 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs @@ -218,9 +218,12 @@ private static void CollectReferencedTypes(INamedTypeSymbol type, HashSet) are unwrapped to their element type + if (IsCollectionType(propertyType, out var elementType)) + { + propertyType = elementType; + } // Handle nullable types if (propertyType is INamedTypeSymbol nullableType && nullableType.IsGenericType) @@ -228,11 +231,9 @@ private static void CollectReferencedTypes(INamedTypeSymbol type, HashSet> ex Expression = expression; var names = new Dictionary(); foreach (var kvp in expressionNames) - names[kvp.Key] = kvp.Value; + names.Add(kvp.Key, kvp.Value); ExpressionNames = names; var values = new Dictionary(); foreach (var kvp in expressionValues) - values[kvp.Key] = kvp.Value; + values.Add(kvp.Key, kvp.Value); ExpressionValues = values; } @@ -308,9 +308,9 @@ public static Condition And(params Condition[] conditions) if (i > 0) sb.Append(" AND "); sb.Append(conditions[i].Expression); foreach (var kvp in conditions[i].ExpressionNames) - expressionNames[kvp.Key] = kvp.Value; + expressionNames.Add(kvp.Key, kvp.Value); foreach (var kvp in conditions[i].ExpressionValues) - expressionValues[kvp.Key] = kvp.Value; + expressionValues.Add(kvp.Key, kvp.Value); } return new Condition(sb.ToString(), expressionNames, expressionValues); } @@ -344,9 +344,9 @@ public static Condition Or(params Condition[] conditions) if (i > 0) sb.Append(" OR "); sb.Append(conditions[i].Expression); foreach (var kvp in conditions[i].ExpressionNames) - expressionNames[kvp.Key] = kvp.Value; + expressionNames.Add(kvp.Key, kvp.Value); foreach (var kvp in conditions[i].ExpressionValues) - expressionValues[kvp.Key] = kvp.Value; + expressionValues.Add(kvp.Key, kvp.Value); } return new Condition(sb.ToString(), expressionNames, expressionValues); } From a355ef783ad59d1e90abc4b24a0daa2ad47e1650 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Tue, 17 Mar 2026 16:32:03 +0000 Subject: [PATCH 33/34] Fixed unwrapping --- .../DynamoMapperIncrementalGenerator.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs index 76f79e01..68fc6c8b 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs @@ -218,9 +218,10 @@ private static void CollectReferencedTypes(INamedTypeSymbol type, HashSet) are unwrapped to their element type - if (IsCollectionType(propertyType, out var elementType)) + // Recursively unwrap collection types (e.g. List> -> MyType) + // before the system type check, so that system collections are resolved + // to their innermost element type + while (IsCollectionType(propertyType, out var elementType)) { propertyType = elementType; } From 0ad0379f6fe64a9b88c8a4fd6a43be6c5d2da8d1 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Tue, 17 Mar 2026 21:06:54 +0000 Subject: [PATCH 34/34] Fixed property name cache ctor --- .../Goa.Clients.Dynamo/Internal/PropertyNameCache.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs b/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs index 2b24d719..8cc45c8f 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs @@ -11,7 +11,11 @@ internal struct PropertyNameCache { private readonly Dictionary _cache; - public PropertyNameCache(int capacity = 16) + public PropertyNameCache() : this(15) + { + } + + public PropertyNameCache(int capacity) { _cache = new Dictionary(capacity); }