< Summary

Information
Class: ArturRios.Data.DynamoDb.Repositories.DynamoRepository<T>
Assembly: ArturRios.Data.DynamoDb
File(s): /home/runner/work/dotnet-data/dotnet-data/src/ArturRios.Data.DynamoDb/Repositories/DynamoRepository.cs
Line coverage
85%
Covered lines: 67
Uncovered lines: 11
Coverable lines: 78
Total lines: 221
Line coverage: 85.8%
Branch coverage
64%
Covered branches: 18
Total branches: 28
Branch coverage: 64.2%
Method coverage
100%
Covered methods: 18
Fully covered methods: 14
Total methods: 18
Method coverage: 100%
Full method coverage: 77.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
SaveAsync(...)100%11100%
LoadAsync(...)100%11100%
LoadAsync(...)100%11100%
DeleteAsync(...)100%11100%
QueryAsync(...)100%11100%
QueryAsync(...)100%11100%
ScanAsync(...)100%11100%
SaveManyAsync(...)100%11100%
DeleteManyAsync(...)100%11100%
LoadManyAsync(...)100%11100%
GuardedAsync()100%1166.66%
GuardedProcessAsync()0%3230%
Fail(...)100%22100%
Log(...)100%44100%
Describe(...)66.66%7675%
IsRetryableServiceFault(...)57.14%151485.71%

File(s)

/home/runner/work/dotnet-data/dotnet-data/src/ArturRios.Data.DynamoDb/Repositories/DynamoRepository.cs

#LineLine coverage
 1using Amazon.DynamoDBv2.DataModel;
 2using Amazon.Runtime;
 3using Amazon.DynamoDBv2.DocumentModel;
 4using Amazon.DynamoDBv2.Model;
 5using System.Runtime.CompilerServices;
 6using ArturRios.Data.DynamoDb.Interfaces;
 7using ArturRios.Output;
 8using Microsoft.Extensions.Logging;
 9
 10namespace ArturRios.Data.DynamoDb.Repositories;
 11
 12/// <summary>
 13///     DynamoDB implementation of <see cref="IAsyncDynamoRepository{T}" /> over the AWS object-persistence
 14///     model (<see cref="IDynamoDBContext" />). Failures are returned as <see cref="DataOutput{T}" /> /
 15///     <see cref="ProcessOutput" />; a <see cref="ConditionalCheckFailedException" /> (from
 16///     <c>[DynamoDBVersion]</c> optimistic locking) becomes a concurrency error.
 17/// </summary>
 18/// <typeparam name="T">The item type.</typeparam>
 19/// <param name="context">The DynamoDB object-persistence context.</param>
 20/// <param name="logger">
 21///     Optional logger. Envelopes never carry service text, so a failure is otherwise
 22///     undiagnosable: supply a logger and the full exception, plus the item type and the
 23///     repository method that failed, is written at <see cref="LogLevel.Error" />. Item contents
 24///     and key values are never logged. Resolved from DI when logging is registered.
 25/// </param>
 2226public class DynamoRepository<T>(IDynamoDBContext context, ILogger<DynamoRepository<T>>? logger = null)
 27    : IAsyncDynamoRepository<T> where T : class
 28{
 29    /// <summary>Message returned when an operation fails with no finer classification.</summary>
 30    protected const string OperationFailedMessage = "A data-access error occurred.";
 31
 32    /// <summary>Message returned on an optimistic-concurrency conflict.</summary>
 33    protected const string ConcurrencyMessage = "Concurrency conflict: the item was modified by another process.";
 34
 35    /// <summary>Message returned when the failure is transient and the operation may be retried.</summary>
 36    protected const string TransientMessage = "The data store is temporarily unavailable. Please retry.";
 37
 38    /// <summary>
 39    ///     The DynamoDB batch-write API rejects types with a <c>[DynamoDBVersion]</c> property unless
 40    ///     version checking is explicitly skipped (batch writes have no per-item conditional-check
 41    ///     support). Optimistic concurrency remains enforced on the single-item <see cref="SaveAsync" />/
 42    ///     <see cref="DeleteAsync" /> paths.
 43    /// </summary>
 244    private static readonly BatchWriteConfig BatchSkipVersionCheckConfig = new() { SkipVersionCheck = true };
 45
 46    /// <inheritdoc />
 47    public Task<DataOutput<T>> SaveAsync(T item, CancellationToken ct = default) =>
 2848        GuardedAsync(async () =>
 2849        {
 2850            await context.SaveAsync(item, ct).ConfigureAwait(false);
 2851
 2252            return item;
 5053        });
 54
 55    /// <inheritdoc />
 56    public Task<DataOutput<T?>> LoadAsync(object hashKey, CancellationToken ct = default) =>
 457        GuardedAsync<T?>(async () => await context.LoadAsync<T>(hashKey, ct).ConfigureAwait(false));
 58
 59    /// <inheritdoc />
 60    public Task<DataOutput<T?>> LoadAsync(object hashKey, object rangeKey, CancellationToken ct = default) =>
 1261        GuardedAsync<T?>(async () => await context.LoadAsync<T>(hashKey, rangeKey, ct).ConfigureAwait(false));
 62
 63    /// <inheritdoc />
 64    public Task<ProcessOutput> DeleteAsync(T item, CancellationToken ct = default) =>
 865        GuardedProcessAsync(async () => await context.DeleteAsync(item, ct).ConfigureAwait(false));
 66
 67    /// <inheritdoc />
 68    public Task<DataOutput<IEnumerable<T>>> QueryAsync(object hashKey, CancellationToken ct = default) =>
 469        GuardedAsync<IEnumerable<T>>(async () => await context.QueryAsync<T>(hashKey).GetRemainingAsync(ct).ConfigureAwa
 70
 71    /// <inheritdoc />
 72    public Task<DataOutput<IEnumerable<T>>> QueryAsync(object hashKey, QueryOperator op,
 73        IEnumerable<object> sortKeyValues, CancellationToken ct = default) =>
 274        GuardedAsync<IEnumerable<T>>(async () =>
 475            await context.QueryAsync<T>(hashKey, op, sortKeyValues).GetRemainingAsync(ct).ConfigureAwait(false));
 76
 77    /// <inheritdoc />
 78    public Task<DataOutput<IEnumerable<T>>> ScanAsync(IEnumerable<ScanCondition> conditions,
 79        CancellationToken ct = default) =>
 480        GuardedAsync<IEnumerable<T>>(async () => await context.ScanAsync<T>(conditions).GetRemainingAsync(ct).ConfigureA
 81
 82    /// <inheritdoc />
 83    public Task<DataOutput<IEnumerable<T>>> SaveManyAsync(IEnumerable<T> items, CancellationToken ct = default) =>
 284        GuardedAsync<IEnumerable<T>>(async () =>
 285        {
 286            var list = items.ToList();
 287            var batch = context.CreateBatchWrite<T>(BatchSkipVersionCheckConfig);
 288            batch.AddPutItems(list);
 289            await batch.ExecuteAsync(ct).ConfigureAwait(false);
 290            return list;
 491        });
 92
 93    /// <inheritdoc />
 94    public Task<ProcessOutput> DeleteManyAsync(IEnumerable<T> items, CancellationToken ct = default) =>
 295        GuardedProcessAsync(async () =>
 296        {
 297            var batch = context.CreateBatchWrite<T>(BatchSkipVersionCheckConfig);
 298            batch.AddDeleteItems(items.ToList());
 299            await batch.ExecuteAsync(ct).ConfigureAwait(false);
 4100        });
 101
 102    /// <inheritdoc />
 103    public Task<DataOutput<IEnumerable<T>>>
 104        LoadManyAsync(IEnumerable<object> hashKeys, CancellationToken ct = default) =>
 4105        GuardedAsync<IEnumerable<T>>(async () =>
 4106        {
 4107            var batch = context.CreateBatchGet<T>();
 24108            foreach (var key in hashKeys)
 4109            {
 8110                batch.AddKey(key);
 4111            }
 4112
 4113            await batch.ExecuteAsync(ct).ConfigureAwait(false);
 4114
 4115            return batch.Results;
 8116        });
 117
 118    /// <summary>Runs an operation returning data, converting failures to envelope errors.</summary>
 119    /// <param name="operation">The operation to run.</param>
 120    /// <param name="operationName">The calling repository method, used as log context.</param>
 121    protected async Task<DataOutput<TResult>> GuardedAsync<TResult>(Func<Task<TResult>> operation,
 122        [CallerMemberName] string operationName = "")
 123    {
 124        try
 125        {
 48126            return DataOutput<TResult>.New.WithData(await operation().ConfigureAwait(false));
 127        }
 0128        catch (OperationCanceledException)
 129        {
 0130            throw;
 131        }
 6132        catch (Exception ex)
 133        {
 6134            return Fail<TResult>(ex, operationName);
 135        }
 48136    }
 137
 138    /// <summary>Runs an operation with no payload, converting failures to envelope errors.</summary>
 139    /// <param name="operation">The operation to run.</param>
 140    /// <param name="operationName">The calling repository method, used as log context.</param>
 141    protected async Task<ProcessOutput> GuardedProcessAsync(Func<Task> operation,
 142        [CallerMemberName] string operationName = "")
 143    {
 144        try
 145        {
 6146            await operation().ConfigureAwait(false);
 6147            return ProcessOutput.New;
 148        }
 0149        catch (OperationCanceledException)
 150        {
 0151            throw;
 152        }
 0153        catch (Exception ex)
 154        {
 0155            Log(ex, operationName);
 156
 0157            return ex is ConditionalCheckFailedException
 0158                ? ProcessOutput.New.WithError(ConcurrencyMessage)
 0159                : ProcessOutput.New.WithError(Describe(ex));
 160        }
 6161    }
 162
 163    /// <summary>
 164    ///     Logs the failure in full when a logger is configured, and maps it to a data-output error
 165    ///     envelope. Service text names tables, indexes, keys and request ids, so it goes to the
 166    ///     log and never to the caller.
 167    /// </summary>
 168    /// <param name="ex">The exception caught by a guard.</param>
 169    /// <param name="operationName">The repository method that failed, used as log context.</param>
 170    protected DataOutput<TResult> Fail<TResult>(Exception ex, string operationName = "")
 171    {
 6172        Log(ex, operationName);
 173
 6174        return ex switch
 6175        {
 2176            ConditionalCheckFailedException => DataOutput<TResult>.New.WithError(ConcurrencyMessage),
 4177            _ => DataOutput<TResult>.New.WithError(Describe(ex))
 6178        };
 179    }
 180
 181    private void Log(Exception ex, string operationName)
 182    {
 183        // A conditional-check failure is the expected outcome of optimistic locking, not an
 184        // operational fault: logging it at Error would fill the log with routine contention.
 6185        var level = ex is ConditionalCheckFailedException ? LogLevel.Debug : LogLevel.Error;
 186
 6187        logger?.Log(level, ex, "DynamoDB operation failed. Item: {Item}, operation: {Operation}",
 6188            typeof(T).Name, operationName);
 2189    }
 190
 191    /// <summary>Classifies a non-concurrency failure into a caller-safe message.</summary>
 192    protected static string Describe(Exception ex)
 193    {
 24194        for (var current = ex; current is not null; current = current.InnerException)
 195        {
 8196            if (current is TimeoutException || IsRetryableServiceFault(current))
 197            {
 0198                return TransientMessage;
 199            }
 200        }
 201
 4202        return OperationFailedMessage;
 203    }
 204
 205    // Throttling and server faults reach the caller under several exception types (and as a plain
 206    // AmazonDynamoDBException with a throttling error code), so classify on what the SDK reports
 207    // about the response rather than on the exception type alone.
 208    private static bool IsRetryableServiceFault(Exception ex)
 209    {
 8210        if (ex is ProvisionedThroughputExceededException or RequestLimitExceededException
 8211            or InternalServerErrorException)
 212        {
 0213            return true;
 214        }
 215
 8216        return ex is AmazonServiceException service &&
 8217               (service.Retryable is not null ||
 8218                (int)service.StatusCode == 429 ||
 8219                (int)service.StatusCode >= 500);
 220    }
 221}