diff --git a/Analyzer/AnalyzerReleases.Unshipped.md b/Analyzer/AnalyzerReleases.Unshipped.md index b732dc4..dbc84bf 100644 --- a/Analyzer/AnalyzerReleases.Unshipped.md +++ b/Analyzer/AnalyzerReleases.Unshipped.md @@ -1,9 +1,9 @@ ### New Rules -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -CAERIUS001 | CaeriusNet.Generator | Error | Type must be sealed. -CAERIUS002 | CaeriusNet.Generator | Error | Type must be partial. -CAERIUS003 | CaeriusNet.Generator | Error | Type must declare a primary constructor with parameters. -CAERIUS004 | CaeriusNet.Generator | Error | [GenerateTvp] requires a non-empty TvpName. -CAERIUS005 | CaeriusNet.Generator | Warning | Parameter type falls back to sql_variant. + Rule ID | Category | Severity | Notes +------------|----------------------|----------|---------------------------------------------------------- + CAERIUS001 | CaeriusNet.Generator | Error | Type must be sealed. + CAERIUS002 | CaeriusNet.Generator | Error | Type must be partial. + CAERIUS003 | CaeriusNet.Generator | Error | Type must declare a primary constructor with parameters. + CAERIUS004 | CaeriusNet.Generator | Error | [GenerateTvp] requires a non-empty TvpName. + CAERIUS005 | CaeriusNet.Generator | Warning | Parameter type falls back to sql_variant. diff --git a/Benchmark/CaeriusNet.Benchmark.csproj b/Benchmark/CaeriusNet.Benchmark.csproj index 370da3f..cad1cb2 100644 --- a/Benchmark/CaeriusNet.Benchmark.csproj +++ b/Benchmark/CaeriusNet.Benchmark.csproj @@ -12,7 +12,7 @@ - + diff --git a/Documentations/docs/.vitepress/config.mts b/Documentations/docs/.vitepress/config.mts index f77d244..8195ac7 100644 --- a/Documentations/docs/.vitepress/config.mts +++ b/Documentations/docs/.vitepress/config.mts @@ -35,9 +35,10 @@ export default defineConfig({ items: [ { text: 'API Reference', link: '/documentation/api' }, { text: 'Best Practices', link: '/documentation/best-practices' }, - { text: 'Diagnostics', link: '/documentation/diagnostics' }, + { text: 'Telemetry & Diagnostics', link: '/documentation/diagnostics' }, ] }, + { text: 'Examples', link: '/examples/' }, { text: 'Performance', link: '/benchmarks/' } ], @@ -79,7 +80,18 @@ export default defineConfig({ items: [ { text: 'API Reference', link: '/documentation/api' }, { text: 'Best Practices', link: '/documentation/best-practices' }, - { text: 'Diagnostics', link: '/documentation/diagnostics' } + { text: 'Telemetry & Diagnostics', link: '/documentation/diagnostics' } + ] + }, + { + text: 'Examples', + collapsed: false, + items: [ + { text: 'Overview', link: '/examples/' }, + { text: 'Stored Procedures', link: '/examples/stored-procedures' }, + { text: 'Table-Valued Parameters', link: '/examples/tvp' }, + { text: 'Multi-Result Sets', link: '/examples/multi-result-sets' }, + { text: 'Transactions', link: '/examples/transactions' } ] }, { diff --git a/Documentations/docs/documentation/advanced-usage.md b/Documentations/docs/documentation/advanced-usage.md index db8a5fb..855cce9 100644 --- a/Documentations/docs/documentation/advanced-usage.md +++ b/Documentations/docs/documentation/advanced-usage.md @@ -1,23 +1,24 @@ # Advanced Usage -This page covers patterns for combining CaeriusNet features in real-world scenarios: mixing TVP with scalar parameters, applying caching selectively, and structuring repositories for maximum reuse. +This page collects patterns that combine multiple CaeriusNet features in real-world scenarios — TVPs alongside scalar parameters, conditional caching, multi-result-set calls with TVP filters, and fine-grained transaction handling. -## TVP + regular parameters +## TVPs combined with scalar parameters Combine `AddTvpParameter` and `AddParameter` to send a structured row set alongside scalar filters: ```sql CREATE PROCEDURE dbo.sp_GetUsers_By_Tvp_Ids_And_Age - @Ids dbo.tvp_int READONLY, - @Age INT + @Ids dbo.tvp_int READONLY, + @Age INT AS BEGIN SET NOCOUNT ON; SELECT Id, Username, Age - FROM dbo.Users - WHERE Id IN (SELECT Id FROM @Ids) - AND Age >= @Age; + FROM dbo.Users + WHERE Id IN (SELECT Id FROM @Ids) + AND Age >= @Age; END +GO ``` ```csharp @@ -25,12 +26,13 @@ END public sealed partial record UserIdTvp(int Id); public async Task> GetUsersByIdsAndAgeAsync( - IEnumerable candidates, int minAge, CancellationToken ct) + IReadOnlyCollection userIds, int minAge, CancellationToken ct) { - var tvpItems = candidates.Take(4242).Select(u => new UserIdTvp(u.Id)).ToList(); - if (tvpItems.Count == 0) return []; + if (userIds.Count == 0) return []; - var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 4242) + var tvpItems = userIds.Select(id => new UserIdTvp(id)); + + var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", capacity: 4096) .AddTvpParameter("Ids", tvpItems) .AddParameter("Age", minAge, SqlDbType.Int) .Build(); @@ -41,7 +43,7 @@ public async Task> GetUsersByIdsAndAgeAsync( ## Conditional caching -Apply different cache strategies depending on input. For example, cache all-users queries but skip caching for filtered queries: +Apply different cache strategies depending on input. For example, cache the unfiltered query but skip caching for filtered queries: ```csharp public async Task> GetUsersAsync( @@ -52,15 +54,15 @@ public async Task> GetUsersAsync( if (minAge.HasValue) builder.AddParameter("Age", minAge.Value, SqlDbType.Int); else - builder.AddFrozenCache("users:all"); // only cache the unfiltered result + builder.AddFrozenCache("users:all"); // only cache the unfiltered result return await DbContext.QueryAsIEnumerableAsync(builder.Build(), ct) ?? []; } ``` -## Multiple result sets with TVP +## Multiple result sets with a TVP filter -Multi-result SP calls support parameters including TVPs. Pass the `StoredProcedureParameters` to any `QueryMultiple*` method: +Multi-result SP calls accept the same parameters as single-result calls — including TVPs and any cache tier: ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Dashboard_Data", 512) @@ -73,123 +75,120 @@ var (users, orders) = await DbContext .QueryMultipleIEnumerableAsync(sp, ct); ``` -## Scalar return after write +## Scalar return after a write -Use `ExecuteScalarAsync` when the SP returns a single value, such as a newly inserted identity: +Use `ExecuteScalarAsync` when the SP returns a single value — for example a newly inserted identity: ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_InsertUser_Return_Id") .AddParameter("Username", username, SqlDbType.NVarChar) - .AddParameter("Age", age, SqlDbType.TinyInt) + .AddParameter("Age", age, SqlDbType.TinyInt) .Build(); -var newId = await DbContext.ExecuteScalarAsync(sp, cancellationToken); +var newId = await DbContext.ExecuteScalarAsync(sp, ct); ``` -## Performance notes +## Performance levers | Technique | Benefit | |---|---| | Pre-sized `resultSetCapacity` | Avoids `List` resizing for large result sets | | `QueryAsImmutableArrayAsync` | Struct-backed, pool-allocated — zero extra copies | | TVP `SqlDataRecord` reuse | Single instance per call regardless of row count | -| `AddFrozenCache` | Eliminates DB round-trip entirely for static data | -| `CancellationToken` propagation | Cancels in-progress SQL command on request abort | - ---- +| `AddFrozenCache` | Eliminates the DB round-trip entirely for static data | +| `CancellationToken` propagation | Cancels in-progress SQL commands when the request is aborted | -## Transaction examples +## Transaction patterns -### Multi-command transaction +### Multi-command unit of work Execute multiple operations atomically — all succeed or all roll back: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); var spOrder = new StoredProcedureParametersBuilder("dbo", "sp_InsertOrder") .AddParameter("UserId", userId, SqlDbType.Int) - .AddParameter("Total", total, SqlDbType.Decimal) + .AddParameter("Total", total, SqlDbType.Decimal) .Build(); -await tx.ExecuteNonQueryAsync(spOrder, cancellationToken); +await tx.ExecuteNonQueryAsync(spOrder, ct); var spInventory = new StoredProcedureParametersBuilder("dbo", "sp_DecrementStock") .AddParameter("ProductId", productId, SqlDbType.Int) - .AddParameter("Quantity", qty, SqlDbType.Int) + .AddParameter("Quantity", qty, SqlDbType.Int) .Build(); -await tx.ExecuteNonQueryAsync(spInventory, cancellationToken); +await tx.ExecuteNonQueryAsync(spInventory, ct); var spBalance = new StoredProcedureParametersBuilder("dbo", "sp_DebitUserBalance") .AddParameter("UserId", userId, SqlDbType.Int) - .AddParameter("Amount", total, SqlDbType.Decimal) + .AddParameter("Amount", total, SqlDbType.Decimal) .Build(); -await tx.ExecuteNonQueryAsync(spBalance, cancellationToken); +await tx.ExecuteNonQueryAsync(spBalance, ct); -await tx.CommitAsync(cancellationToken); +await tx.CommitAsync(ct); ``` ### Conditional rollback -Roll back based on business logic without an exception: +Roll back based on business logic without raising an exception: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); var spCheck = new StoredProcedureParametersBuilder("dbo", "sp_GetAvailableStock", 1) .AddParameter("ProductId", productId, SqlDbType.Int) .Build(); -var stock = await tx.ExecuteScalarAsync(spCheck, cancellationToken); +var stock = await tx.ExecuteScalarAsync(spCheck, ct); if (stock < requestedQuantity) { - await tx.RollbackAsync(cancellationToken); + await tx.RollbackAsync(ct); return OrderResult.InsufficientStock; } var spReserve = new StoredProcedureParametersBuilder("dbo", "sp_ReserveStock") - .AddParameter("ProductId", productId, SqlDbType.Int) - .AddParameter("Quantity", requestedQuantity, SqlDbType.Int) + .AddParameter("ProductId", productId, SqlDbType.Int) + .AddParameter("Quantity", requestedQuantity, SqlDbType.Int) .Build(); -await tx.ExecuteNonQueryAsync(spReserve, cancellationToken); -await tx.CommitAsync(cancellationToken); +await tx.ExecuteNonQueryAsync(spReserve, ct); +await tx.CommitAsync(ct); return OrderResult.Reserved; ``` -### Poison state handling +### Poison-state handling -Handle failures gracefully when a command poisons the transaction: +When a command fails, the scope poisons. Roll back and let the caller retry the entire unit of work: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); try { - await tx.ExecuteNonQueryAsync(sp1, cancellationToken); - await tx.ExecuteNonQueryAsync(sp2, cancellationToken); // may fail - await tx.CommitAsync(cancellationToken); + await tx.ExecuteNonQueryAsync(sp1, ct); + await tx.ExecuteNonQueryAsync(sp2, ct); // may throw — poisons the scope + await tx.CommitAsync(ct); } catch (CaeriusNetSqlException ex) { logger.LogWarning(ex, "Transaction poisoned by {Procedure}", ex.ProcedureName); - await tx.RollbackAsync(cancellationToken); - // Retry the entire operation at the caller level - throw; + await tx.RollbackAsync(ct); + throw; // retry at the caller — never partially recover } ``` -## Logging configuration +## Logging configuration in detail ### Setting up the logger with DI -Configure CaeriusNet logging during application startup. The logger must be set before any database operations: +Configure CaeriusNet logging during application startup. The logger must be set before any database operations are performed: ```csharp var builder = WebApplication.CreateBuilder(args); @@ -197,7 +196,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddLogging(logging => { logging.AddConsole(); - logging.AddFilter("CaeriusNet", LogLevel.Information); + logging.AddFilter("CaeriusNet", LogLevel.Information); logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); }); @@ -208,21 +207,20 @@ CaeriusNetBuilder var app = builder.Build(); -// Set the logger after building the service provider +// LoggerProvider is wired automatically when ILoggerFactory is in DI; +// the explicit call below is only needed if you build without DI. LoggerProvider.SetLogger(app.Services.GetRequiredService()); ``` -### Filtering by category - -Use event ID ranges to control verbosity per subsystem: +### Filtering by category in `appsettings.json` ```json { "Logging": { "LogLevel": { - "Default": "Information", - "CaeriusNet.Cache": "Warning", - "CaeriusNet.Commands": "Debug" + "Default": "Information", + "CaeriusNet.Cache": "Warning", + "CaeriusNet.Commands": "Debug" } } } diff --git a/Documentations/docs/documentation/api.md b/Documentations/docs/documentation/api.md index 5b28f15..23fb998 100644 --- a/Documentations/docs/documentation/api.md +++ b/Documentations/docs/documentation/api.md @@ -1,24 +1,32 @@ # API Reference -This section provides a practical reference for the main public APIs exposed by the CaeriusNet package. All examples use C# 14/.NET 10 and Microsoft.Data.SqlClient. +This page is a practical reference for the public API exposed by CaeriusNet. All examples target **C# 14 / .NET 10** with `Microsoft.Data.SqlClient`. Namespaces are abbreviated below — add the matching `using` directives in your project. -- Namespaces shown below are abbreviated; use your project’s using directives accordingly. -- Code examples assume Dependency Injection is configured via CaeriusNetBuilder. +> Examples assume DI is configured via `CaeriusNetBuilder` (see [Installation & Setup](/quickstart/getting-started)). ## Builders -### CaeriusNet.Builders.CaeriusNetBuilder +### `CaeriusNet.Builders.CaeriusNetBuilder` + Configures CaeriusNet services for DI. + ```csharp static CaeriusNetBuilder Create(IServiceCollection services); static CaeriusNetBuilder Create(IHostApplicationBuilder builder); + CaeriusNetBuilder WithSqlServer(string connectionString); CaeriusNetBuilder WithRedis(string? connectionString); + CaeriusNetBuilder WithAspireSqlServer(string connectionName = "sqlserver"); CaeriusNetBuilder WithAspireRedis(string connectionName = "redis"); + +CaeriusNetBuilder WithTelemetryOptions(CaeriusTelemetryOptions options); + IServiceCollection Build(); ``` + Example: + ```csharp CaeriusNetBuilder .Create(services) @@ -27,78 +35,144 @@ CaeriusNetBuilder .Build(); ``` -### CaeriusNet.Builders.StoredProcedureParametersBuilder -Fluent builder for stored-procedure execution settings, parameters, and caching. +### `CaeriusNet.Builders.StoredProcedureParametersBuilder` + +Fluent builder for Stored Procedure execution settings, parameters, and caching. + +**Constructor:** -Constructor: ```csharp -StoredProcedureParametersBuilder(string schemaName, string procedureName, int resultSetCapacity = 1); +StoredProcedureParametersBuilder( + string schemaName, + string procedureName, + int resultSetCapacity = 1); ``` -Parameter methods: +**Parameter methods:** + ```csharp -StoredProcedureParametersBuilder AddParameter(string parameter, object value, SqlDbType dbType); -StoredProcedureParametersBuilder AddTvpParameter(string parameter, IEnumerable items) where T : class, ITvpMapper; +StoredProcedureParametersBuilder AddParameter( + string parameter, + object value, + SqlDbType dbType); + +StoredProcedureParametersBuilder AddTvpParameter( + string parameter, + IEnumerable items) + where T : class, ITvpMapper; ``` -Caching methods: +**Caching methods:** + ```csharp StoredProcedureParametersBuilder AddInMemoryCache(string cacheKey, TimeSpan expiration); -StoredProcedureParametersBuilder AddFrozenCache(string cacheKey); -StoredProcedureParametersBuilder AddRedisCache(string cacheKey, TimeSpan? expiration = null); +StoredProcedureParametersBuilder AddFrozenCache (string cacheKey); +StoredProcedureParametersBuilder AddRedisCache (string cacheKey, TimeSpan? expiration = null); ``` -Build: +**Build:** + ```csharp -StoredProcedureParameters Build() +StoredProcedureParameters Build(); ``` -Example (read with capacity): +Example with capacity, parameters, TVP, and Redis cache: + ```csharp -var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", resultSetCapacity: 250) +var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 1024) + .AddTvpParameter("Ids", tvpItems) // T : class, ITvpMapper + .AddParameter ("Age", age, SqlDbType.Int) + .AddRedisCache ($"users:age:{age}", TimeSpan.FromMinutes(2)) .Build(); ``` -Example (parameters + TVP + cache): +## Telemetry + +### `CaeriusNet.Telemetry.CaeriusTelemetryOptions` + +Configuration record consumed globally by every command pipeline. + ```csharp -var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 1024) - .AddTvpParameter("Ids", tvpItems) // T implements ITvpMapper - .AddParameter("Age", age, SqlDbType.Int) - .AddRedisCache("users:age:" + age, TimeSpan.FromMinutes(2)) - .Build(); +public sealed record CaeriusTelemetryOptions +{ + public bool CaptureParameterValues { get; init; } = false; +} +``` + +| Property | Default | Description | +|---|---|---| +| `CaptureParameterValues` | `false` | When `true`, the `caerius.sp.parameters` tag shows `@name=value`. TVP values always render as `[TVP]`. Off by default for PII safety. | + +### `CaeriusNet.Telemetry.CaeriusDiagnostics` + +Static registry of OpenTelemetry signals. + +```csharp +public static class CaeriusDiagnostics +{ + public const string SourceName = "CaeriusNet"; + public static ActivitySource ActivitySource { get; } + public static Meter Meter { get; } +} ``` +Register in your telemetry pipeline (typically in `ServiceDefaults`): + +```csharp +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource(CaeriusDiagnostics.SourceName)) + .WithMetrics(m => m.AddMeter (CaeriusDiagnostics.SourceName)); +``` + +See [Aspire Integration — Tracing & Telemetry](/documentation/aspire#tracing-telemetry) for the complete tag and metric reference. + ## Abstractions -### CaeriusNet.Abstractions.ICaeriusNetDbContext -Represents a factory for opening SQL connections and exposes an optional Redis cache manager. +### `CaeriusNet.Abstractions.ICaeriusNetDbContext` + +Factory for opening SQL connections; exposes the optional Redis cache manager. -Properties: ```csharp IRedisCacheManager? RedisCacheManager { get; } +ValueTask DbConnectionAsync(CancellationToken ct = default); ``` -Methods: +### `CaeriusNet.Abstractions.ICaeriusNetTransaction` + +Transaction scope obtained via `BeginTransactionAsync`. See [Transactions](/documentation/transactions) for the full contract. + ```csharp -SqlConnection DbConnection() +ValueTask CommitAsync (CancellationToken ct = default); +ValueTask RollbackAsync (CancellationToken ct = default); +ValueTask DisposeAsync (); + +// Same Execute*/Query* surface as ICaeriusNetDbContext, enlisted in the transaction. ``` -### CaeriusNet.Abstractions.IRedisCacheManager +### `CaeriusNet.Abstractions.IRedisCacheManager` + Distributed cache adapter used when Redis is configured. + ```csharp bool TryGet(string cacheKey, out T? value); -void Store(string cacheKey, T value, TimeSpan? expiration) where T : notnull +void Store (string cacheKey, T value, TimeSpan? expiration) where T : notnull; ``` ## Mappers -### CaeriusNet.Mappers.ISpMapper -Compile-time friendly contract for mapping a row from SqlDataReader. +### `CaeriusNet.Mappers.ISpMapper` + +Compile-time-friendly contract for mapping a row from `SqlDataReader`. + ```csharp -static abstract T MapFromDataReader(SqlDataReader reader) +public interface ISpMapper where TSelf : ISpMapper +{ + static abstract TSelf MapFromDataReader(SqlDataReader reader); +} ``` -Usage (manual): +Manual implementation: + ```csharp public sealed record UserDto(int Id, string Name) : ISpMapper { @@ -107,22 +181,30 @@ public sealed record UserDto(int Id, string Name) : ISpMapper } ``` -### CaeriusNet.Mappers.ITvpMapper -Defines how to convert items to an `IEnumerable` for TVP. +### `CaeriusNet.Mappers.ITvpMapper` + +Defines how to convert items to `IEnumerable` for TVP transport. + ```csharp -static abstract string TvpTypeName { get; } -IEnumerable MapAsSqlDataRecords(IEnumerable items) +public interface ITvpMapper where TSelf : class, ITvpMapper +{ + static abstract string TvpTypeName { get; } + IEnumerable MapAsSqlDataRecords(IEnumerable items); +} ``` -Usage (manual): +Manual implementation: + ```csharp -public sealed record UsersIdsTvp(int Id) : ITvpMapper +public sealed record UserIdsTvp(int Id) : ITvpMapper { public static string TvpTypeName => "dbo.tvp_int"; - public IEnumerable MapAsSqlDataRecords(IEnumerable items) + + public IEnumerable MapAsSqlDataRecords(IEnumerable items) { var metaData = new[] { new SqlMetaData("Id", SqlDbType.Int) }; - var record = new SqlDataRecord(metaData); + var record = new SqlDataRecord(metaData); + foreach (var item in items) { record.SetInt32(0, item.Id); @@ -134,97 +216,173 @@ public sealed record UsersIdsTvp(int Id) : ITvpMapper ## Attributes (Source Generators) -### CaeriusNet.Attributes.Dto.GenerateDtoAttribute -Annotate sealed partial records/classes to generate `ISpMapper` at compile time. +### `CaeriusNet.Attributes.Dto.GenerateDtoAttribute` + +Annotate a sealed partial record/class to generate `ISpMapper` at compile time. + ```csharp [GenerateDto] public sealed partial record UserDto(int Id, string Name, byte? Age); ``` -### CaeriusNet.Attributes.Tvp.GenerateTvpAttribute -Annotate sealed partial records/classes to generate `ITvpMapper`. +### `CaeriusNet.Attributes.Tvp.GenerateTvpAttribute` + +Annotate a sealed partial record/class to generate `ITvpMapper`. + +**Required init properties:** -Required init properties: ```csharp -string Schema { get; init; } = "dbo" +string Schema { get; init; } = "dbo"; string TvpName { get; init; } ``` -Exemple: + +Example: + ```csharp [GenerateTvp(Schema = "Types", TvpName = "tvp_Int")] public sealed partial record UsersIntTvp(int UserId); ``` -## Read Commands -Extension methods on ICaeriusNetDbContext for reading result sets. +See [Source Generators](/documentation/source-generators) for the complete generator behaviour and [Compiler Diagnostics](/documentation/diagnostics) for the analyzer rules. + +## Read commands + +Extension methods on `ICaeriusNetDbContext` for reading result sets. + +Namespace: `CaeriusNet.Commands.Reads.SimpleReadSqlAsyncCommands` -Namespace: CaeriusNet.Commands.Reads.SimpleReadSqlAsyncCommands ```csharp -ValueTask FirstQueryAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); -ValueTask> QueryAsReadOnlyCollectionAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); -ValueTask?> QueryAsIEnumerableAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); -ValueTask> QueryAsImmutableArrayAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); +ValueTask + FirstQueryAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); + +ValueTask> + QueryAsReadOnlyCollectionAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); + +ValueTask?> + QueryAsIEnumerableAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); + +ValueTask> + QueryAsImmutableArrayAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); ``` -Each TResultSet must be a class implementing `ISpMapper`. +`TResult` must be a class implementing `ISpMapper`. Example: + ```csharp var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250).Build(); -var users = await dbContext.QueryAsIEnumerableAsync(sp); +var users = await dbContext.QueryAsIEnumerableAsync(sp, ct); ``` -## Write Commands -Extension methods on ICaeriusNetDbContext for non-query operations. +## Write commands + +Extension methods on `ICaeriusNetDbContext` for non-query operations. + +Namespace: `CaeriusNet.Commands.Writes.WriteSqlAsyncCommands` -Namespace: CaeriusNet.Commands.Writes.WriteSqlAsyncCommands ```csharp -ValueTask ExecuteScalarAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); -ValueTask ExecuteNonQueryAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); -ValueTask ExecuteAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); +ValueTask ExecuteScalarAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); +ValueTask ExecuteNonQueryAsync (this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); +ValueTask ExecuteAsync (this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); ``` Examples: + ```csharp -// Get affected rows +// Affected rows var sp = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserAge_By_Guid") .AddParameter("Guid", guid, SqlDbType.UniqueIdentifier) - .AddParameter("Age", age, SqlDbType.TinyInt) + .AddParameter("Age", age, SqlDbType.TinyInt) .Build(); -var affected = await dbContext.ExecuteNonQueryAsync(sp); -// Fire-and-forget (no count) -await dbContext.ExecuteAsync(sp); +var affected = await dbContext.ExecuteNonQueryAsync(sp, ct); + +// Fire-and-forget +await dbContext.ExecuteAsync(sp, ct); ``` -## Multiple Result Sets -Namespace: CaeriusNet.Commands.Reads.MultiIEnumerableReadSqlAsyncCommands +## Multiple result sets + +Up to **5 result sets** per call. Each return type maps positionally to a `SELECT` statement in the SP. + +Namespace: `CaeriusNet.Commands.Reads.MultiIEnumerableReadSqlAsyncCommands` (similar shapes exist for `ReadOnlyCollection` and `ImmutableArray`). + ```csharp -Task<(IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); -Task<(IEnumerable, IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(...); -Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(...); -Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(...); +Task<(IEnumerable, IEnumerable)> + QueryMultipleIEnumerableAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken); + +Task<(IEnumerable, IEnumerable, IEnumerable)> + QueryMultipleIEnumerableAsync(/* ... */); + +Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable)> + QueryMultipleIEnumerableAsync(/* ... */); + +Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable, IEnumerable)> + QueryMultipleIEnumerableAsync(/* ... */); ``` Example: + ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Dashboard_Data", 128).Build(); + var (users, orders, products) = await dbContext - .QueryMultipleIEnumerableAsync(sp); + .QueryMultipleIEnumerableAsync(sp, ct); +``` + +## Transactions + +Open a transaction scope from the DB context. See [Transactions](/documentation/transactions) for the complete state-machine and tracing reference. + +```csharp +ValueTask + BeginTransactionAsync(this ICaeriusNetDbContext, IsolationLevel level, CancellationToken ct = default); +``` + +Example: + +```csharp +await using var tx = await dbContext.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); +await tx.ExecuteNonQueryAsync(sp1, ct); +await tx.ExecuteNonQueryAsync(sp2, ct); +await tx.CommitAsync(ct); ``` ## Caching -Caching is configured per-call via StoredProcedureParametersBuilder and resolved using: -- Frozen (in-process, immutable) -- InMemory (in-process, expirable) -- Redis (distributed, optional) + +Caching is configured per call on `StoredProcedureParametersBuilder`: + +- **Frozen** — in-process, immutable +- **InMemory** — in-process, expirable +- **Redis** — distributed (optional, requires `WithRedis(...)` or `WithAspireRedis(...)`) Example: + ```csharp var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) - .AddFrozenCache("all_users_frozen") + .AddFrozenCache("users:all:frozen") .Build(); -var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp); + +var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct); +``` + +See [Caching](/documentation/cache) for the full guide. + +## Exceptions + +### `CaeriusNet.Exceptions.CaeriusNetSqlException` + +Wraps `SqlException` from any failed SQL command. The original exception is available via `InnerException`. Carries the originating procedure name to simplify diagnostics: + +```csharp +try +{ + await dbContext.ExecuteAsync(sp, ct); +} +catch (CaeriusNetSqlException ex) when (ex.InnerException is SqlException sqlEx) +{ + logger.LogError(ex, "SP {Procedure} failed (error {Number})", ex.ProcedureName, sqlEx.Number); +} ``` -For Redis, configure CaeriusNetBuilder.WithRedis(...) or WithAspireRedis(...). +The active OpenTelemetry span is tagged `ActivityStatusCode.Error` before the exception bubbles up. diff --git a/Documentations/docs/documentation/aspire.md b/Documentations/docs/documentation/aspire.md index b944645..413e991 100644 --- a/Documentations/docs/documentation/aspire.md +++ b/Documentations/docs/documentation/aspire.md @@ -1,16 +1,18 @@ # Aspire Integration -CaeriusNet provides first-class integration with [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) through `WithAspireSqlServer` and `WithAspireRedis` builder methods. Aspire manages connection strings via its resource abstraction — CaeriusNet resolves them automatically. +CaeriusNet provides first-class integration with [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) through `WithAspireSqlServer` and `WithAspireRedis`. Aspire manages SQL Server and Redis as named resources in the AppHost; CaeriusNet resolves their connection strings automatically. + +This page also documents the **OpenTelemetry signals** that CaeriusNet emits, regardless of whether you use Aspire — you only need to register them with your telemetry pipeline. ## Prerequisites - .NET 10 and a .NET Aspire AppHost project -- `CaeriusNet` NuGet package in your service project -- SQL Server and (optionally) Redis resources declared in AppHost +- The `CaeriusNet` NuGet package in your service project +- SQL Server and (optionally) Redis resources declared in the AppHost ## AppHost configuration -In your Aspire AppHost project, declare SQL Server and Redis resources and pass their references to your service: +Declare SQL Server and Redis in the AppHost and pass their references to your service: ```csharp // AppHost/Program.cs @@ -30,18 +32,18 @@ builder.Build().Run(); ## Service project configuration -In your service's `Program.cs` or `Startup.cs`, use `WithAspireSqlServer` and `WithAspireRedis`. These methods resolve the connection string from Aspire's named connection: +In your service's `Program.cs`, use `WithAspireSqlServer` and `WithAspireRedis`. These methods resolve the connection string from Aspire's named connection registry: ::: code-group -```csharp [With Redis] +```csharp [SQL Server + Redis] using CaeriusNet.Builders; var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); // Aspire service defaults +builder.AddServiceDefaults(); // Aspire ServiceDefaults CaeriusNetBuilder .Create(builder) - .WithAspireSqlServer("sqlserver") // matches AppHost resource name + .WithAspireSqlServer("sqlserver") // matches the AppHost resource name .WithAspireRedis("redis") // optional distributed cache .Build(); @@ -65,9 +67,9 @@ app.Run(); ``` ::: -## Console app / Worker Service pattern +## Console / Worker Service pattern -For console apps or background workers using Aspire: +For console apps or background workers running under Aspire: ```csharp using CaeriusNet.Builders; @@ -83,8 +85,6 @@ CaeriusNetBuilder .Build(); var host = builder.Build(); - -// Inject ICaeriusNetDbContext via DI in your hosted services host.Run(); ``` @@ -100,7 +100,7 @@ CaeriusNetBuilder .Build(); ``` -## Resource name matching +## Resource-name matching The string passed to `WithAspireSqlServer` and `WithAspireRedis` must match the resource name declared in the AppHost: @@ -110,7 +110,8 @@ The string passed to `WithAspireSqlServer` and `WithAspireRedis` must match the | `builder.AddRedis("redis")` | `.WithAspireRedis("redis")` | ::: tip Default names -If you use the conventional names `"sqlserver"` and `"redis"` you can also use the parameter-less overloads: +If you use the conventional names `"sqlserver"` and `"redis"`, you can also call the parameter-less overloads: + ```csharp .WithAspireSqlServer() // defaults to "sqlserver" .WithAspireRedis() // defaults to "redis" @@ -121,7 +122,7 @@ If you use the conventional names `"sqlserver"` and `"redis"` you can also use t ```csharp // AppHost/Program.cs -var sql = builder.AddSqlServer("sqlserver").AddDatabase("CaeriusDb"); +var sql = builder.AddSqlServer("sqlserver").AddDatabase("CaeriusDb"); var redis = builder.AddRedis("redis"); builder.AddProject("api") .WithReference(sql) @@ -162,6 +163,100 @@ public sealed record UserRepository(ICaeriusNetDbContext DbContext) } ``` +## Telemetry options + +Use `WithTelemetryOptions` to configure how CaeriusNet records spans and metrics. Options are applied globally and consulted by every command pipeline. + +```csharp +CaeriusNetBuilder.Create(builder) + .WithAspireSqlServer("sqlserver") + .WithAspireRedis() + .WithTelemetryOptions(new CaeriusTelemetryOptions + { + // Include parameter names AND values in caerius.sp.parameters. + // ⚠ Enable only outside production — values may contain PII + // (names, emails, tokens, monetary amounts, etc.). + CaptureParameterValues = true + }) + .Build(); +``` + +| Option | Type | Default | Description | +|---|---|---|---| +| `CaptureParameterValues` | `bool` | `false` | When `true`, the `caerius.sp.parameters` tag shows `@name=value` pairs instead of just `@name`. TVP values are always shown as `[TVP]`. | + +::: warning Production guidance +`CaptureParameterValues = true` is convenient in staging or development to correlate a trace with the exact parameters that produced it. In production, parameter values can contain sensitive data (user IDs, emails, amounts …) and should generally **not** be emitted to a shared telemetry backend. +::: + +## Tracing & telemetry {#tracing-telemetry} + +CaeriusNet emits OpenTelemetry-compatible signals through the BCL primitives — no OpenTelemetry SDK package is added to the library itself. Consumers (typically the Aspire `ServiceDefaults` project) opt in by registering the source and meter: + +```csharp +// ServiceDefaults/Extensions.cs +using CaeriusNet.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource(CaeriusDiagnostics.SourceName)) // "CaeriusNet" + .WithMetrics(m => m.AddMeter (CaeriusDiagnostics.SourceName)); // "CaeriusNet" +``` + +When no listener is subscribed, no allocation is performed. + +### Spans + +Every Stored Procedure call creates an `Activity` of `ActivityKind.Client` named `SP {schema}.{procedure}`. Failures set `ActivityStatusCode.Error` and attach the SQL exception via `Activity.AddException`. + +| Tag | Description | +|---|---| +| `db.system` | Always `mssql` (OpenTelemetry semantic convention) | +| `db.operation` | The calling command (`FirstQueryAsync`, `QueryMultipleImmutableArrayAsync`, `ExecuteNonQueryAsync`, …) | +| `db.statement` | `{schema}.{procedure}` | +| `caerius.sp.schema` | Schema of the Stored Procedure | +| `caerius.sp.name` | Name of the Stored Procedure | +| `caerius.sp.parameters` | Comma-separated parameter names (e.g. `@id,@tvp`); shows `@name=value` when `CaptureParameterValues = true`. TVP values always render as `[TVP]`. | +| `caerius.sp.command` | Same as `db.operation` (kept for filter convenience) | +| `caerius.tvp.used` | `true` when at least one TVP is attached | +| `caerius.tvp.type_name` | TVP type name (e.g. `dbo.tvp_int`); comma-separated when several TVPs are used | +| `caerius.resultset.multi` | `true` when more than one result set is requested | +| `caerius.resultset.expected_count` | Number of result sets requested (1 by default; 2/3/4/5 for the multi-RS overloads) | +| `caerius.cache.tier` / `caerius.cache.hit` | Set on the active span when a cache lookup happens during the call | +| `caerius.tx` | `true` when the call runs inside an `ICaeriusNetTransaction` | +| `caerius.rows_returned` / `caerius.rows_affected` | Set on success | + +### Metrics + +Four instruments are exposed by the `CaeriusNet` meter, all tagged with the same `caerius.sp.*` dimensions as the spans: + +| Instrument | Type | Unit | Purpose | +|---|---|---|---| +| `caerius.sp.duration` | Histogram | ms | Stored Procedure execution duration | +| `caerius.sp.executions` | Counter | calls | Number of executions started (success or failure) | +| `caerius.sp.errors` | Counter | calls | Number of executions that failed with a SQL error | +| `caerius.cache.lookups` | Counter | lookups | Cache lookups, tagged with `caerius.cache.tier` (`Frozen` / `InMemory` / `Redis`) and `caerius.cache.hit` (`true` / `false`) | + +When a cache hit short-circuits the SQL call, **no DB span is created** and only `caerius.cache.lookups{hit=true}` is emitted — the Aspire dashboard accurately shows the database was not contacted. + +## Transaction tracing {#transaction-tracing} + +Every `ICaeriusNetTransaction` scope emits a parent **`TX` span** (kind = Internal) that wraps all child SP spans. This produces a single cohesive trace in the Aspire dashboard instead of one orphaned span per command: + +```text +TX (kind=Internal, caerius.tx.isolation_level=ReadCommitted, caerius.tx.outcome=committed) +├── SP Users.usp_Create_User (kind=Client, caerius.tx=true) +└── SP Users.usp_Create_Order (kind=Client, caerius.tx=true) +``` + +| Tag | Description | +|---|---| +| `caerius.tx.isolation_level` | The SQL Server isolation level (e.g. `ReadCommitted`) | +| `caerius.tx.outcome` | `committed`, `rolled-back`, `auto-rollback`, `poisoned-auto-rollback`, `commit-failed`, `rollback-failed` | + +::: tip SQL-side rollback in the dashboard +A Stored Procedure that wraps its own `BEGIN TRY / BEGIN CATCH` rolls back internally and re-throws, which surfaces as a `CaeriusNetSqlException`. The corresponding SP span is tagged `ActivityStatusCode.Error` — this is **expected** and intentional, not a CaeriusNet bug. +::: + --- **Next:** [API Reference](/documentation/api) — full surface of all public types and methods. diff --git a/Documentations/docs/documentation/best-practices.md b/Documentations/docs/documentation/best-practices.md index 1f7c871..1d5bdbe 100644 --- a/Documentations/docs/documentation/best-practices.md +++ b/Documentations/docs/documentation/best-practices.md @@ -1,189 +1,197 @@ -# Best Practices and Guidelines +# Best Practices -This guide distills practical recommendations for building reliable, secure, and high‑performance applications with CaeriusNet. It complements the Get Started, Usage, and Advanced Usage pages. +This page distills practical recommendations for building reliable, secure, and high-performance applications with CaeriusNet. It complements the [Quickstart](/quickstart/getting-started), [Reading Data](/documentation/reading-data), [Writing Data](/documentation/writing-data), and [Advanced Usage](/documentation/advanced-usage) guides. -Applies to: C# 14 / .NET 10, SQL Server 2019+, Microsoft.Data.SqlClient. +> Applies to: **C# 14 / .NET 10**, **SQL Server 2019 +**, `Microsoft.Data.SqlClient`. -## Architecture and Patterns +## Architecture & patterns -- Prefer the Repository pattern. Keep data access isolated in repositories behind interfaces. -- Use Dependency Injection to obtain ICaeriusNetDbContext where needed. -- Favor sealed records for DTOs and TVPs. They are immutable, lightweight, and great with source generators. -- Prefer source generators: -```csharp - [GenerateDto] // to auto-generate ISpMapper for DTOs. - [GenerateTvp(Schema = "", TvpName = "")] // to auto-generate ITvpMapper for TVPs. -``` -- Keep mapping and database concerns out of controllers/services. Services orchestrate repositories. - -## DTO Mapping Guidelines - -- Mapping is ordinal-based. The constructor parameter order of your DTO must match the column order in the SQL result set. - - Column names don’t affect mapping. Aliases can improve readability but are not required for mapping. -- Use nullable types for columns that may return NULL from SQL Server. -- Keep DTOs minimal and purpose‑specific. Avoid large catch‑all DTOs. -- Example (manual mapping): -```csharp -public sealed record UserDto(int Id, string Name, byte? Age) - : ISpMapper -{ - public static UserDto MapFromDataReader(SqlDataReader reader) - => new(reader.GetInt32(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetByte(2)); -} -``` +- **Use the Repository pattern.** Keep data access isolated behind interfaces — `ICaeriusNetDbContext` is the only external dependency repositories should know about. +- **Inject `ICaeriusNetDbContext` via DI.** Never instantiate it manually; the lifetime and connection management is handled by the framework. +- **Favour sealed records.** They are immutable, lightweight, value-equatable, and pair well with the source generators. +- **Always prefer source generators.** The analyzer enforces the contract and the generated code is identical to a hand-written mapper: + ```csharp + [GenerateDto] + public sealed partial record UserDto(int Id, string Name, byte? Age); + + [GenerateTvp(Schema = "dbo", TvpName = "tvp_int")] + public sealed partial record UserIdTvp(int Id); + ``` +- **Keep mapping out of services and controllers.** Repositories own SQL; services orchestrate them. + +## DTO mapping + +- Mapping is **ordinal-based** — the constructor parameter order **must** match the SP `SELECT` column order. Aliases are cosmetic. +- Mark columns that may return `NULL` as nullable in the DTO; the source generator emits `IsDBNull` guards automatically. +- Keep DTOs **purpose-specific**. Avoid catch-all DTOs that grow with every screen. +- Manual mapping example for reference: + ```csharp + public sealed record UserDto(int Id, string Name, byte? Age) : ISpMapper + { + public static UserDto MapFromDataReader(SqlDataReader reader) + => new( + reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetByte(2)); + } + ``` + +## Stored Procedure conventions (T-SQL) + +- **Use dedicated schemas** (`Users`, `Orders`, `Sales`, …) instead of the default `dbo` for application code where feasible. +- Always start procedures with `SET NOCOUNT ON;` to avoid spurious result sets. +- **Never `SELECT *`.** Explicitly list columns in the order your DTO expects. +- Keep result-set shape stable. Any change in cardinality or order requires a matching DTO change. +- Prefer parameterized procedures for all inputs — avoid dynamic SQL unless absolutely required. +- Wrap multi-statement writes in `BEGIN TRY / BEGIN CATCH` with explicit `COMMIT` / `ROLLBACK` and `THROW;` for re-raise. +- Adopt a consistent name convention, e.g. `Schema.sp_Action_Subject_By_Filter`. + +## TVPs + +- Define SQL types with the **minimal columns** needed. +- Use `[GenerateTvp]` for zero-boilerplate mappers. +- Ensure the .NET TVP record's primary-constructor parameters match the SQL column definition exactly (order, nullability, types). +- Pass TVPs `READONLY` (required by SQL Server). +- Validate non-empty input before calling — `AddTvpParameter` throws `ArgumentException` on empty collections. +- For very large sets, batch into reasonable chunks (e.g. 5 000–10 000 rows per call) to keep memory and CPU usage predictable. + +## Caching strategy + +Pick the right tier per call via `StoredProcedureParametersBuilder`: + +- **Frozen** — in-process, immutable, fastest. **Only** for data that truly never changes during the application lifetime (lookup tables, currency codes, permission definitions). +- **InMemory** — in-process with TTL. Good for hot paths where some staleness is acceptable. Always set a sensible expiration. +- **Redis** — distributed. Use in multi-instance deployments. Secure with TLS + auth in production. -## Stored Procedure Guidelines (T/SQL) - -- Use dedicated schemas (e.g., App, Users, Sales). Avoid dbo for application code where feasible. -- Always SET NOCOUNT ON inside procedures to avoid extra result sets. -- Keep result shapes stable. Any change in cardinality or order requires a matching DTO change. -- Avoid SELECT *. Explicitly list columns in the exact order your DTO expects. -- Prefer parameterized procedures for all inputs. Avoid dynamic SQL unless absolutely required. -- Use TRY/CATCH with explicit COMMIT/ROLLBACK for transactional procedures when necessary. -- Keep procedure names task‑based and consistent: schema.sp_Action_Subject_By_Filter. - -## TVP (Table‑Valued Parameter) Guidance - -- Create SQL types with the minimal necessary columns and indexes where relevant. -- Use `[GenerateTvp]` for zero-boilerplate TVP mapping. The generator uses `IEnumerable` internally for efficient streaming. -- Ensure the .NET TVP columns (constructor parameters) match the SQL TVP type definition exactly. -- Keep TVP payload sizes reasonable. Extremely large TVPs can increase CPU and memory usage on both ends. -- Pass TVPs read‑only (as required by SQL Server) and consider batching if sets are very large. - -## Caching Strategy - -Choose the right cache per call via StoredProcedureParametersBuilder: - -- Frozen cache - - In‑process, immutable, fastest. - - Use only for static reference data that rarely/never changes (e.g., lookup tables). - - No expiration, cleared when the process restarts. -- In‑memory cache - - In‑process with expiration. Good for hot paths where staleness is acceptable. - - Always set a sensible expiration. -- Redis cache - - Distributed, optional. Use in multi‑instance deployments for shared caching. - - Secure with TLS and auth. Set expirations aligned with your invalidation strategy. - -Cache key design: -- Deterministic, short, and descriptive (e.g., users:age:>=30). -- Include input parameters and stable identifiers. -- Prefer lowercase and colon separators. - -Invalidation: -- Prefer time‑based expiry for mutable data. -- For Frozen cache, only cache immutable data to avoid invalidation complexity. - -## Performance Tips - -- Set ResultSetCapacity accurately to minimize list/array resizing. -- Return only required columns. Avoid over‑fetching. -- Avoid unnecessary allocations in hot paths; use ReadOnlyCollection/ImmutableArray variants where appropriate. -- Benchmark critical flows. Keep an eye on memory pressure for Frozen and In‑Memory caches. -- For very large multi‑result queries, use the MultiIEnumerable APIs to stream sets efficiently. -- `SearchValues` SIMD validation: CaeriusNet uses `SearchValues` for parameter name validation — near-zero cost character scanning on modern CPUs. -- Lock-free `FrozenCache` reads: The Frozen cache uses `FrozenDictionary` for zero-contention concurrent reads without locks. -- `GC.AllocateUninitializedArray`: Large result arrays use uninitialized allocation to skip zero-fill overhead when the array will be fully populated. - -## Transaction Best Practices - -- Keep transactions as short as possible — hold locks only for the duration of the actual database work. -- Always use `await using` to guarantee `DisposeAsync` runs on all code paths. -- Pass `CancellationToken` to all transactional methods — this cancels in-flight SQL commands if the request is aborted. -- Use the lowest isolation level that meets your consistency requirements (`ReadCommitted` is the default). -- Avoid performing non-database I/O (HTTP calls, file writes) inside a transaction scope. -- Retry the entire transaction at the caller level if a poison state occurs — do not attempt partial recovery. +**Cache keys:** + +- Deterministic, short, descriptive (e.g. `users:age:>=30`). +- Include all parameters that affect the result. +- Lowercase, colon-separated. + +**Invalidation:** + +- Prefer time-based expiry for mutable data. +- For Frozen, only cache truly immutable data — there is no invalidation hook. + +See [Caching](/documentation/cache) for the full guide. + +## Performance + +- **Set `resultSetCapacity` accurately.** It pre-allocates the `List` and avoids resize churn for large reads. +- **Return only required columns.** Less data = fewer allocations and faster TDS framing. +- **Pick the right collection.** `ImmutableArray` for frozen / shared data; `ReadOnlyCollection` for public APIs; `IEnumerable` for LINQ pipelines. +- **Stream multi-result-sets** with the `QueryMultipleIEnumerableAsync` family rather than chaining separate calls. +- **Benchmark critical flows.** CaeriusNet's own [BenchmarkDotNet suites](/benchmarks/) are reproducible (`Random(42)`); use them as a baseline. + +Internal performance levers worth knowing about: + +| Mechanism | What it buys you | +|---|---| +| `SearchValues` SIMD scans | Near-zero-cost parameter-name validation on modern CPUs | +| `FrozenDictionary` for Frozen cache | Lock-free concurrent reads | +| `GC.AllocateUninitializedArray` | Skips zero-fill on large result arrays that will be fully populated | +| `CollectionsMarshal.SetCount` + `AsSpan` | Populates `List` in place without bounds checks per write | + +## Transactions + +- Use `await using` so `DisposeAsync` runs on every code path — including exceptions. +- Pass `CancellationToken` everywhere; it cancels in-flight SQL commands. +- **Keep transactions short.** They hold locks; long transactions block other readers and writers. +- **Don't perform non-database I/O** (HTTP calls, file writes, log shipping) inside a transaction scope. +- Use the **lowest isolation level** that meets your consistency requirements (`ReadCommitted` is the default). +- **Retry the entire transaction** at the caller level if the scope poisons — never attempt partial recovery. ```csharp -// ✅ Short, focused transaction with cancellation -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +// ✅ Short, focused, cancellable +await using var tx = await dbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); -await tx.ExecuteNonQueryAsync(spDebit, cancellationToken); -await tx.ExecuteNonQueryAsync(spCredit, cancellationToken); -await tx.CommitAsync(cancellationToken); +await tx.ExecuteNonQueryAsync(spDebit, ct); +await tx.ExecuteNonQueryAsync(spCredit, ct); +await tx.CommitAsync(ct); ``` -## Logging Best Practices +## Logging -- Configure `LoggerProvider.SetLogger(...)` early in application startup — before any database calls. -- Filter by event ID range to reduce noise: suppress cache events (1xxx–3xxx) in production; keep command execution (5xxx) at `Information`. -- Use structured logging sinks (Seq, Elasticsearch, OTLP) to leverage CaeriusNet's named parameters (`{ProcedureName}`, `{Duration}`, `{RowCount}`). -- Set up alerts on event ID 5003 (slow execution threshold) and 5004 (command failure) for production monitoring. +- Configure `ILoggerFactory` **before** `CaeriusNetBuilder.Build()` — DI then wires the logger automatically. +- Filter by event-ID category to control verbosity per subsystem: + - `CaeriusNet.Cache` → `Warning` in production + - `CaeriusNet.Commands` → `Information` to keep timing visible +- Use **structured sinks** (Seq, Elasticsearch, OTLP) to leverage CaeriusNet's named placeholders (`{ProcedureName}`, `{Duration}`, `{RowCount}`). +- Set up alerts on event ID **5003** (slow execution) and **5004** (command failure) for production monitoring. ```csharp builder.Services.AddLogging(logging => { - logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); // quiet in prod - logging.AddFilter("CaeriusNet.Commands", LogLevel.Information); // keep timing + logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); + logging.AddFilter("CaeriusNet.Commands", LogLevel.Information); }); ``` -## Async, Cancellation, and Reliability +See [Logging & Observability](/documentation/logging) for the complete event-ID reference. -- All APIs are asynchronous by design. Don’t block on async calls. -- Propagate CancellationToken from the request boundary to database calls. -- Configure reasonable command/connection timeouts in connection strings or command options. -- Open one connection per operation via ICaeriusNetDbContext.DbConnection(), and dispose it promptly (handled by library helpers). +## Async, cancellation, reliability -## Error Handling and Troubleshooting +- All APIs are asynchronous by design — never block on async calls (`.Result`, `.Wait()`). +- Propagate `CancellationToken` from the request boundary down to every database call. +- Configure reasonable command and connection timeouts in your connection string. +- The library opens, uses, and disposes connections automatically — do not manage `SqlConnection` instances manually. -Common issues and resolutions: +## Error handling -- Connection issues (Aspire) - - Ensure WithAspireSqlServer("name") matches your AppHost AddSqlServer/AddDatabase name. - - Confirm the connection string is available at runtime (AppHost injection). -- TVP type mismatch - - Verify schema and type name (Schema.TvpName) match between SQL and .NET. - - Ensure constructor parameters and SQL TVP columns align (order and types). -- Mapping errors - - InvalidCastException typically indicates an ordinal/type mismatch. Check DTO constructor order and SQL SELECT order. -- Cache misses - - Verify identical cache keys and parameters across calls. For Redis, check connectivity and configuration. -- Memory growth - - Frozen cache is immutable and monotonic. Use only for true constants. - - Tune In‑Memory cache expiration and avoid caching very large payloads unnecessarily. +| Issue | Likely cause | Fix | +|---|---|---| +| `InvalidCastException` at runtime | Reader method or DTO type doesn't match SQL column type | Align the `Get*` call (or DTO field type) with the actual SQL type | +| `IndexOutOfRangeException` | DTO has more parameters than the SP returns columns | Re-check `SELECT` arity and DTO parameter count | +| Aspire connection failure | `WithAspireSqlServer("name")` does not match AppHost name | Cross-check the resource name in the AppHost | +| TVP type mismatch | Schema/TvpName diverge between SQL and .NET, or column order is wrong | Re-align `[GenerateTvp]` arguments and constructor params | +| Cache miss when hit was expected | Different keys per call | Build keys deterministically from inputs | +| Memory growth (Frozen) | Frozen used for non-static data | Switch to InMemory with TTL | -## Security Considerations +## Security -- Use stored procedures with parameters to avoid SQL injection. -- Do not cache sensitive data unless necessary and lawful. Prefer short expirations and encryption at rest/in‑transit. -- Secure Redis with TLS and authentication; restrict network access to trusted environments. -- Limit SQL permissions for the application user to the minimum required. +- Use Stored Procedures with parameters — no string concatenation, no dynamic SQL. +- **Never cache sensitive data** (passwords, tokens, raw PII) without explicit threat-modelling. +- Secure Redis with TLS and authentication; restrict network access. +- Grant the application user the **minimum required SQL permissions** (`EXECUTE` on the SP schema, no `dbo`). -## Versioning and Migrations +## Versioning & migrations -- Treat stored procedures and DTOs as a contract. Version them when breaking changes are needed. -- Add new procedures and DTOs alongside existing ones during migrations, then deprecate old versions. +- Treat Stored Procedures and DTOs as a contract — version them when breaking changes are needed (e.g. `sp_GetUsers` → `sp_GetUsers_v2`). +- Add new procedures alongside existing ones; deprecate old ones once consumers migrate. -## Examples +## Quick reference -- Read with caching (In‑memory): ```csharp +// Read with caching var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) .AddInMemoryCache("users:all", TimeSpan.FromMinutes(2)) .Build(); -var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken); + +var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct); ``` -- Write with affected rows: ```csharp +// Update with affected rows var sp = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserAge_By_Guid") .AddParameter("Guid", guid, SqlDbType.UniqueIdentifier) - .AddParameter("Age", age, SqlDbType.TinyInt) + .AddParameter("Age", age, SqlDbType.TinyInt) .Build(); -int rows = await dbContext.ExecuteNonQueryAsync(sp, cancellationToken); + +var rows = await dbContext.ExecuteNonQueryAsync(sp, ct); ``` -- TVP example: ```csharp -var tvpItems = new List { new(1), new(2), new(3) }; -var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", 256) - .AddTvpParameter("Ids", tvpItems) +// TVP read +var tvp = userIds.Select(id => new UserIdTvp(id)); +var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", 256) + .AddTvpParameter("Ids", tvp) .Build(); -var users = await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken); + +var users = await dbContext.QueryAsIEnumerableAsync(sp, ct); ``` --- -Use this page as a checklist when designing new queries or optimizing existing ones. For detailed APIs and examples, see [Reading Data](/documentation/reading-data), [Writing Data](/documentation/writing-data), [Caching](/documentation/cache), and [API Reference](/documentation/api). \ No newline at end of file +Use this page as a checklist when designing new queries or auditing existing ones. For deeper APIs and patterns, see [API Reference](/documentation/api), [Advanced Usage](/documentation/advanced-usage), and the [Examples](/examples/). diff --git a/Documentations/docs/documentation/cache.md b/Documentations/docs/documentation/cache.md index b11c14d..b997d0a 100644 --- a/Documentations/docs/documentation/cache.md +++ b/Documentations/docs/documentation/cache.md @@ -1,53 +1,53 @@ # Caching -CaeriusNet supports three caching strategies to reduce database load and improve latency. Caching is opt-in per call via `StoredProcedureParametersBuilder`. When a cache hit occurs, CaeriusNet returns the cached result without executing the Stored Procedure. +CaeriusNet supports three caching strategies to reduce database load and improve latency. Caching is **opt-in per call** via `StoredProcedureParametersBuilder` — you choose the tier exactly where you need it. On a cache hit, CaeriusNet returns the cached result without executing the Stored Procedure and **without creating a DB span**. ## Strategy comparison -| Strategy | Scope | Expiration | Requires setup | Best for | +| Strategy | Scope | Expiration | DI prerequisite | Best for | |---|---|---|---|---| -| **Frozen** | In-process | None — lives until process restart | None | Static reference data (e.g., lookup tables, enums) | -| **InMemory** | In-process | Required (`TimeSpan`) | None | Frequently read data with acceptable staleness | -| **Redis** | Distributed | Optional | `WithRedis` / `WithAspireRedis` | Multi-instance deployments, shared cache | +| **Frozen** | In-process | None — lives until process restart | None | Static reference data (lookup tables, enums, country codes) | +| **InMemory** | In-process | Required (`TimeSpan`) | None | Frequently-read data with acceptable staleness | +| **Redis** | Distributed | Optional (`TimeSpan?`) | `WithRedis` / `WithAspireRedis` | Multi-instance deployments, shared cache, surviving restarts | ## Frozen cache -Immutable, in-process cache. Entries persist until the process restarts. No expiration configuration. +Immutable, in-process cache backed by `FrozenDictionary` for lock-free reads. Entries persist until the process restarts; there is no expiration. ```csharp var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) - .AddFrozenCache("all_users_frozen") + .AddFrozenCache("users:all:frozen") .Build(); -var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken); +var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct); ``` ::: tip When to use Frozen -Use Frozen for data that truly never changes during the application lifetime: country lists, currency codes, permission definitions, static lookup tables. +Frozen is for data that **truly never changes** for the lifetime of the process: country lists, currency codes, permission definitions, static lookup tables. If the data needs an invalidation strategy, prefer InMemory or Redis. ::: ## In-memory cache -In-process cache with a mandatory expiration `TimeSpan`. Entries are automatically evicted after the specified duration. +In-process cache with a mandatory `TimeSpan` expiration. Entries are evicted automatically when their TTL elapses. ```csharp var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) - .AddInMemoryCache("all_users_memory", TimeSpan.FromMinutes(1)) + .AddInMemoryCache("users:all:memory", TimeSpan.FromMinutes(1)) .Build(); -var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken); +var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct); ``` ::: tip When to use InMemory -Use InMemory for data that is read frequently but can tolerate slight staleness: user profiles, product catalogues, configuration records that change infrequently. +InMemory is for data read frequently within a single process where some staleness is acceptable: user profiles, product catalogues, configuration records that change infrequently. ::: ## Redis cache (distributed) -Distributed cache backed by Redis. Expiration is optional — if omitted, the entry persists until Redis evicts it. Requires `WithRedis` or `WithAspireRedis` in the builder. +Distributed cache backed by Redis through `Microsoft.Extensions.Caching.StackExchangeRedis`. Expiration is **optional** — when omitted, the entry persists until Redis evicts it under memory pressure. ```csharp -// Required: configure Redis in your DI setup +// Required: configure Redis once in your DI setup CaeriusNetBuilder .Create(services) .WithSqlServer(connectionString) @@ -56,59 +56,82 @@ CaeriusNetBuilder ``` ```csharp -// Per-call: with expiration +// Per-call: with explicit expiration var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) - .AddRedisCache("all_users_redis", TimeSpan.FromMinutes(2)) + .AddRedisCache("users:all:redis", TimeSpan.FromMinutes(2)) .Build(); -// Per-call: no expiration (entry persists until Redis eviction) +// Per-call: no expiration (entry persists until Redis evicts it) var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) - .AddRedisCache("all_users_redis") + .AddRedisCache("users:all:redis") .Build(); ``` ::: tip When to use Redis -Use Redis when multiple instances of your service share the same cache, or when you need cache to survive application restarts. Combine with Aspire for automatic connection management. +Redis is for **multi-instance** deployments where the cache must be shared across replicas, or where it must survive application restarts. Combine with Aspire (`WithAspireRedis`) for automatic connection-string resolution from the AppHost. ::: ## Cache key design -- **Deterministic**: keys must be stable across calls with the same logical input -- **Scoped**: include parameters that affect the result -- **Compact**: avoid very long keys -- **Readable**: use lowercase with `:` separators +Good cache keys are **deterministic**, **scoped to the input**, and **readable**: ```csharp -// Good examples +// ✅ Good "users:all" $"users:age:{age}" $"orders:user:{userId}:page:{page}" -// Avoid -$"result_{DateTime.UtcNow}" // non-deterministic -"very_long_key_with_lots_of_data_that_is_hard_to_read" +// ❌ Avoid +$"result_{DateTime.UtcNow}" // non-deterministic — never hits +"all_user_data_cached_safely" // not scoped to inputs — collisions ``` +Recommended conventions: + +- Lowercase, colon-separated segments +- Most-stable prefix first (`entity`), then identifier, then variant +- Include all parameters that affect the result — otherwise the cache returns the wrong rows + ## Cache invalidation -CaeriusNet does not provide explicit cache invalidation APIs — caches are TTL-based. For Frozen cache, restart the process or deploy a new version. For InMemory, tune the `TimeSpan`. For Redis, use `IDatabase.KeyDelete` directly if needed between deployments. +CaeriusNet does not provide explicit invalidation APIs — caches are TTL-based by design. Strategies: + +- **Frozen** — restart the process or deploy a new version +- **InMemory** — pick a TTL that matches your acceptable staleness +- **Redis** — call `IDatabase.KeyDelete` directly (e.g., on a deployment hook) or rely on TTL + +## Cache and transactions + +**Caches are bypassed inside `ICaeriusNetTransaction` scopes.** This prevents uncommitted (dirty) reads from being published to the cache and served to other consumers. After `CommitAsync`, the next non-transactional call populates the cache normally. + +See [Transactions — Cache bypass](/documentation/transactions#cache-bypass). + +## Telemetry + +Every cache lookup is recorded — both hits and misses — through the `caerius.cache.lookups` counter: + +| Tag | Value | +|---|---| +| `caerius.cache.tier` | `Frozen`, `InMemory`, or `Redis` | +| `caerius.cache.hit` | `true` on hit, `false` on miss | + +On a hit, **no DB span is created** — the trace accurately reflects that the database was not contacted. ## Security considerations -- Do not cache data that mixes users without a user-scoped key (e.g., `$"users:profile:{userId}"`) -- For Redis in production: enable TLS, use authentication, and restrict network access -- Avoid caching sensitive data (passwords, tokens, PII) unless the risk is acceptable and the cache is properly secured +- **Per-user data** must include the user identifier in the key (`$"profile:{userId}"`). +- **Production Redis** should enable TLS, require authentication, and restrict network access to trusted environments. +- **Sensitive data** (passwords, tokens, PII) should not be cached unless the risk is acceptable and the cache is properly secured. Prefer short TTLs. ## Troubleshooting -| Issue | Likely cause | Fix | +| Symptom | Likely cause | Fix | |---|---|---| -| Cache miss when hit expected | Different key on each call | Use a deterministic key construction | -| Stale data returned | Expiration too long | Lower the `TimeSpan` or use a scoped key | -| Redis not used | Not configured in builder | Add `.WithRedis(...)` or `.WithAspireRedis(...)` | -| Memory growth | Frozen cache overused | Reserve Frozen for truly static data | +| Cache miss when a hit was expected | Different key on each call | Construct keys deterministically from inputs | +| Stale data returned | TTL too long | Lower the `TimeSpan` or include a version segment | +| `WithRedis` not used | Redis not configured | Add `.WithRedis(...)` or `.WithAspireRedis(...)` to the builder | +| Memory growth | Frozen cache misused | Reserve Frozen for truly static data; prefer InMemory with TTL otherwise | --- -**Next:** [Aspire Integration](/documentation/aspire) — configure SQL Server and Redis via .NET Aspire. - +**Next:** [Transactions](/documentation/transactions) — atomic multi-statement units of work with parent `TX` spans. diff --git a/Documentations/docs/documentation/diagnostics.md b/Documentations/docs/documentation/diagnostics.md index ef88a27..ab4ccc9 100644 --- a/Documentations/docs/documentation/diagnostics.md +++ b/Documentations/docs/documentation/diagnostics.md @@ -1,27 +1,27 @@ # Compiler Diagnostics -CaeriusNet's Roslyn analyzer emits compile-time diagnostics for `[GenerateDto]` and `[GenerateTvp]` usage so configuration and mapping problems show up before runtime. +CaeriusNet's Roslyn analyzer emits **compile-time diagnostics** for `[GenerateDto]` and `[GenerateTvp]` so contract problems show up in your IDE — never at runtime. The analyzer ships **inside the `CaeriusNet` NuGet package**; no extra reference is required. ## Diagnostic reference -| ID | Severity | Title | Description | +| ID | Severity | Title | Triggered when | |---|---|---|---| | [CAERIUS001](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS001.md) | Error | Type must be `sealed` | A `[GenerateDto]` or `[GenerateTvp]` type is missing the `sealed` modifier | | [CAERIUS002](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS002.md) | Error | Type must be `partial` | A `[GenerateDto]` or `[GenerateTvp]` type is missing the `partial` modifier | -| [CAERIUS003](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS003.md) | Error | Primary constructor required | The type does not use a primary constructor for its parameters | -| [CAERIUS004](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS004.md) | Error | `[GenerateTvp]` requires a non-empty `TvpName` | The attribute explicitly sets `TvpName` to an empty or whitespace string | -| [CAERIUS005](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS005.md) | Warning | Unmapped CLR type falls back to `sql_variant` | A constructor parameter has no native SQL Server mapping | +| [CAERIUS003](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS003.md) | Error | Primary constructor required | The type does not declare a primary constructor (or the constructor has no parameters) | +| [CAERIUS004](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS004.md) | Error | `[GenerateTvp]` requires a non-empty `TvpName` | The attribute sets `TvpName` to an empty or whitespace-only string | +| [CAERIUS005](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS005.md) | Warning | Unmapped CLR type falls back to `sql_variant` | A constructor parameter has a type with no native SQL Server mapping | ## Severity levels | Severity | Build impact | Action required | |---|---|---| -| **Error** | Fails compilation | Must fix before building | -| **Warning** | Compilation succeeds | Review and fix or suppress intentionally | +| **Error** | Fails compilation | Must be fixed before the project builds | +| **Warning** | Compilation succeeds | Review and fix, or suppress intentionally | -## How to suppress diagnostics +## Suppressing diagnostics -### Per-declaration with `#pragma` +### Per declaration with `#pragma` ```csharp #pragma warning disable CAERIUS005 @@ -34,11 +34,11 @@ public sealed partial record FlexibleDto(int Id, object DynamicValue); ```ini [*.cs] -# Suppress specific diagnostic +# Suppress entirely dotnet_diagnostic.CAERIUS005.severity = none -# Downgrade to suggestion -dotnet_diagnostic.CAERIUS005.severity = suggestion +# Or downgrade to a suggestion +# dotnet_diagnostic.CAERIUS005.severity = suggestion ``` ### Via `` in `.csproj` @@ -49,7 +49,7 @@ dotnet_diagnostic.CAERIUS005.severity = suggestion ``` -## How to escalate severity +## Escalating severity Promote warnings to errors to enforce stricter rules in CI: @@ -70,17 +70,17 @@ dotnet_diagnostic.CAERIUS005.severity = error ``` ::: tip CI enforcement -Escalating CAERIUS005 to an error in your CI `.editorconfig` prevents accidental `sql_variant` usage from reaching production. +Escalating `CAERIUS005` to an error in your CI `.editorconfig` prevents an accidental `sql_variant` fallback from reaching production. ::: ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| No diagnostics emitted | Analyzer not referenced | Ensure the `CaeriusNet.Analyzer` project or packaged analyzer is referenced as an analyzer | -| Diagnostics not appearing in IDE | IDE cache stale | Restart IDE or run `dotnet build` from CLI | -| Suppression not working | Wrong scope | Ensure `#pragma` wraps the type declaration, not just the file | +| No diagnostics emitted | Analyzer not loaded | Ensure the `CaeriusNet` package is referenced (the analyzer ships in the same package) | +| Diagnostics not visible in IDE | IDE cache stale | Restart the IDE or run `dotnet build` from the CLI | +| `#pragma` suppression has no effect | Wrong scope | Wrap the **type declaration**, not the file or a member | --- -**See also:** [Source Generators](/documentation/source-generators) — full guide to `[GenerateDto]` and `[GenerateTvp]` code generation. +**See also:** [Source Generators](/documentation/source-generators) — the full guide to `[GenerateDto]` and `[GenerateTvp]`. diff --git a/Documentations/docs/documentation/dto-mapping.md b/Documentations/docs/documentation/dto-mapping.md index 1368b57..3cc7b64 100644 --- a/Documentations/docs/documentation/dto-mapping.md +++ b/Documentations/docs/documentation/dto-mapping.md @@ -1,16 +1,20 @@ # DTO Mapping -CaeriusNet maps SQL Server result sets to C# DTOs at **compile time** — no reflection, no dynamic expression trees, no surprises at runtime. +CaeriusNet maps SQL Server result sets to C# DTOs at **compile time** — no reflection, no expression-tree compilation, no runtime metadata lookups. This page explains how the contract works, what it requires from your DTOs, and the few special cases worth knowing. ## How it works -Every DTO must implement `ISpMapper`, a static-interface contract that provides a single `MapFromDataReader` method. Reads are **ordinal-based**: columns are accessed by index rather than by name, which eliminates per-row string lookups and matches the TDS wire protocol directly. +Every DTO implements `ISpMapper`, a static-interface contract with a single method: ```csharp public static abstract T MapFromDataReader(SqlDataReader reader); ``` -## The `ISpMapper` interface +Reads are **ordinal-based**: each column is accessed by its zero-based index rather than by name. This eliminates per-row string lookups and matches the TDS wire protocol directly — the column order in your `SELECT` statement is the contract between SQL and C#. + +You can implement `ISpMapper` manually, or — preferably — let the source generator emit it for you with `[GenerateDto]`. Both produce the same machine code; the generator simply removes the boilerplate. + +## A manual `ISpMapper` ```csharp using CaeriusNet.Mappers; @@ -27,21 +31,19 @@ public sealed record UserDto(int Id, string Username, byte Age) } ``` -Column indices **must match** the `SELECT` column order in your Stored Procedure. Declare your DTO properties in the same order as the SP result columns. +The constructor parameter order **must** match the `SELECT` column order in your Stored Procedure. -## Column order contract +## Column order is the contract -The column at position `0` in the result set is read by ordinal `0`. Your Stored Procedure defines the contract: +The column at position `0` in the result set is read at ordinal `0`. Your Stored Procedure defines the contract: ```sql -- Columns: Id (0), Username (1), Age (2) SELECT Id, Username, Age -FROM dbo.Users -WHERE Age >= @Age +FROM dbo.Users +WHERE Age >= @Age; ``` -The matching DTO: - ```csharp public sealed record UserDto(int Id, string Username, byte Age) : ISpMapper @@ -57,7 +59,7 @@ If the SP `SELECT` order changes, update the ordinal indices in `MapFromDataRead ## Nullable columns -Use `reader.IsDBNull(ordinal)` before reading nullable columns. For nullable reference types (`string?`) and nullable value types (`int?`): +Use `reader.IsDBNull(ordinal)` before reading nullable columns. The pattern applies equally to nullable reference types (`string?`) and nullable value types (`int?`): ```csharp public sealed record ItemDto(int Id, string? Description, int? Quantity) @@ -71,18 +73,21 @@ public sealed record ItemDto(int Id, string? Description, int? Quantity) } ``` +The source generator emits these guards automatically when the constructor parameter is declared nullable. + ## Special type conversions -Some C# types require explicit conversion from their SQL Server equivalents: +A handful of C# types do not have a direct `Get*` method on `SqlDataReader` and require an explicit conversion: -| C# type | SQL type | Conversion | +| C# type | SQL type | Conversion expression | |---|---|---| | `DateOnly` | `date` / `datetime2` | `DateOnly.FromDateTime(reader.GetDateTime(n))` | | `TimeOnly` | `time` | `TimeOnly.FromDateTime(reader.GetDateTime(n))` | -| `byte[]` | `varbinary` | `(byte[])reader.GetValue(n)` | +| `Half` | `real` | `(Half)reader.GetFloat(n)` | +| `byte[]` | `varbinary` / `image` | `reader.GetFieldValue(n)` | | `ushort` | `int` | `(ushort)reader.GetInt32(n)` | -Example with `DateOnly` and `byte[]`: +Example combining `DateOnly` and `byte[]`: ```csharp public sealed record DocumentDto(int Id, DateOnly CreatedDate, byte[] Content) @@ -92,16 +97,18 @@ public sealed record DocumentDto(int Id, DateOnly CreatedDate, byte[] Content) => new( reader.GetInt32(0), DateOnly.FromDateTime(reader.GetDateTime(1)), - (byte[])reader.GetValue(2)); + reader.GetFieldValue(2)); } ``` +The source generator handles all of the above automatically. + ## Enum mapping -Enums map to their underlying integer type in SQL. Cast the reader result: +Enums map to their underlying integer type in SQL. Cast the reader result accordingly: ```csharp -public enum UserStatus : byte { Active = 1, Inactive = 0 } +public enum UserStatus : byte { Inactive = 0, Active = 1 } public sealed record UserDto(int Id, UserStatus Status) : ISpMapper @@ -113,7 +120,7 @@ public sealed record UserDto(int Id, UserStatus Status) ## Source generation (recommended) -Writing `MapFromDataReader` manually is straightforward but repetitive. Use `[GenerateDto]` to have the compiler emit it for you. See the [Source Generators](/documentation/source-generators) page for full details. +Writing `MapFromDataReader` manually is straightforward but repetitive. Annotate your DTO with `[GenerateDto]` and the generator emits the implementation at build time: ```csharp using CaeriusNet.Attributes.Dto; @@ -122,14 +129,16 @@ using CaeriusNet.Attributes.Dto; public sealed partial record UserDto(int Id, string Username, byte Age); ``` +The DTO must be `sealed`, `partial`, and use a primary constructor — the [Roslyn analyzer](/documentation/diagnostics) reports a build error if any of these are missing. See the [Source Generators](/documentation/source-generators) page for the full list of features. + ## Common pitfalls -| Issue | Fix | -|---|---| -| `InvalidCastException` at runtime | Reader method doesn't match the SQL column type | -| `IndexOutOfRangeException` | Ordinal index doesn't correspond to actual column count | -| Null reference exception | Nullable column not guarded with `IsDBNull` | -| Wrong field values | SP column order doesn't match the DTO constructor order | +| Symptom | Likely cause | Fix | +|---|---|---| +| `InvalidCastException` at runtime | Reader method does not match the SQL column type | Align the `Get*` call (or DTO field type) with the actual SQL type | +| `IndexOutOfRangeException` | Ordinal index does not correspond to an actual column | Re-check the SP `SELECT` arity | +| `NullReferenceException` on a column | Nullable column not guarded with `IsDBNull` | Make the field nullable or add the guard | +| Wrong values for several fields | SP column order does not match constructor parameter order | Re-align the SELECT with the constructor | --- diff --git a/Documentations/docs/documentation/logging.md b/Documentations/docs/documentation/logging.md index 19de4c4..20ca2b4 100644 --- a/Documentations/docs/documentation/logging.md +++ b/Documentations/docs/documentation/logging.md @@ -1,6 +1,8 @@ -# Logging & Observability +# Logging & Observability -CaeriusNet uses `[LoggerMessage]` source-generated logging for zero-allocation structured log output. Every database operation emits timing, procedure name, and result metadata — enabling diagnostics, performance monitoring, and alerting without custom instrumentation. +CaeriusNet uses **source-generated** `[LoggerMessage]` logging for zero-allocation structured output. Every database operation emits timing, procedure name, and result metadata — enabling diagnostics, performance monitoring, and alerting without custom instrumentation. + +For OpenTelemetry tracing and metrics, see [Aspire Integration — Tracing & Telemetry](/documentation/aspire#tracing-telemetry). ## Overview @@ -8,29 +10,30 @@ CaeriusNet uses `[LoggerMessage]` source-generated logging for zero-allocation s |---|---| | **Zero-allocation** | `[LoggerMessage]` source generators — no string interpolation at runtime | | **Structured parameters** | Named placeholders (`{ProcedureName}`, `{Duration}`, `{RowCount}`) | -| **Execution timing** | `Stopwatch.GetElapsedTime` for high-resolution elapsed measurement | -| **Event ID convention** | Categorized by subsystem (see table below) | +| **Execution timing** | `Stopwatch.GetElapsedTime` for high-resolution measurement | +| **Event-ID convention** | Categorized by subsystem (see table below) | | **Provider-agnostic** | Works with any `ILogger` implementation | ## Configuration ### Setting the logger -CaeriusNet uses a static `LoggerProvider` to obtain the logger instance. Configure it during application startup: +CaeriusNet uses a static `LoggerProvider` to obtain its logger instance. Configure it once during application startup: ```csharp using CaeriusNet.Logging; var builder = WebApplication.CreateBuilder(args); -// After building the service provider, set the logger +// ... DI registration ... + var app = builder.Build(); LoggerProvider.SetLogger(app.Services.GetRequiredService()); ``` -### Integration with dependency injection +### Integration with DI -When using `CaeriusNetBuilder`, the logger is configured automatically if `ILoggerFactory` is registered in the DI container: +When using `CaeriusNetBuilder`, the logger is wired automatically as long as `ILoggerFactory` is registered in the DI container — `LoggerProvider.SetLogger` is called for you during `Build()`. ```csharp var builder = WebApplication.CreateBuilder(args); @@ -47,36 +50,32 @@ CaeriusNetBuilder .Build(); ``` -### Filtering by event ID range +### Filtering by category -Use `ILoggerFactory` configuration to filter CaeriusNet logs by event ID ranges: +Use `ILoggerFactory` configuration to filter CaeriusNet logs by subsystem: ```csharp builder.Services.AddLogging(logging => { - logging.AddFilter("CaeriusNet", LogLevel.Information); - - // Show only command execution events (5xxx) - logging.AddFilter("CaeriusNet.Commands", LogLevel.Debug); - - // Suppress cache hit/miss noise in production - logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); + logging.AddFilter("CaeriusNet", LogLevel.Information); + logging.AddFilter("CaeriusNet.Commands", LogLevel.Debug); // verbose for command execution + logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); // suppress per-call cache noise }); ``` -## Event ID categories +## Event-ID categories CaeriusNet organizes event IDs by subsystem. Use these ranges to filter, route, or alert on specific categories: | Range | Category | Description | |---|---|---| -| **1000–1999** | In-Memory Cache | Cache hit, miss, set, eviction | -| **2000–2999** | Frozen Cache | Cache hit, miss, freeze operations | -| **3000–3999** | Redis Cache | Redis get, set, connection events | -| **4000–4999** | Database / Connection | Connection open, close, pool events | -| **5000–5999** | Command Execution | Start, complete, duration, row count | +| **1000–1999** | In-Memory cache | Hit, miss, set, eviction | +| **2000–2999** | Frozen cache | Hit, miss, freeze operations | +| **3000–3999** | Redis cache | Get, set, connection events | +| **4000–4999** | Database / connection | Connection open, close, pool events | +| **5000–5999** | Command execution | Start, complete, duration, row count | -### Detailed event reference +### Event reference | Event ID | Level | Message template | |---|---|---| @@ -100,20 +99,20 @@ CaeriusNet organizes event IDs by subsystem. Use these ranges to filter, route, ## Structured parameters -CaeriusNet logs use semantic (structured) parameters. These are preserved as key-value pairs by structured logging sinks: +CaeriusNet log messages use semantic (structured) placeholders. Structured logging sinks preserve them as queryable key/value pairs: -| Parameter | Type | Description | +| Placeholder | Type | Description | |---|---|---| -| `{ProcedureName}` | `string` | Fully qualified stored procedure name | +| `{ProcedureName}` | `string` | Fully qualified Stored Procedure name (`schema.name`) | | `{Duration}` | `TimeSpan` | Elapsed execution time | | `{RowCount}` | `int` | Number of rows returned or affected | -| `{CacheKey}` | `string` | Cache key used for lookup/store | +| `{CacheKey}` | `string` | Cache key used for lookup or store | | `{Expiration}` | `TimeSpan` | Cache entry TTL | | `{ErrorMessage}` | `string` | Exception message on failure | | `{IsolationLevel}` | `string` | Transaction isolation level | -::: tip Structured logging benefits -Structured parameters enable powerful queries: "Show all executions of `sp_GetUsers` that took longer than 500ms" or "Count cache misses per key in the last hour." +::: tip Why this matters +Structured parameters unlock powerful queries — *"all executions of `sp_GetUsers` slower than 500 ms"*, *"cache misses per key in the last hour"*, *"failure rate by procedure name"* — without parsing log text. ::: ## Integration examples @@ -148,7 +147,7 @@ Filter CaeriusNet events in `appsettings.json`: "MinimumLevel": { "Default": "Information", "Override": { - "CaeriusNet.Cache": "Warning", + "CaeriusNet.Cache": "Warning", "CaeriusNet.Commands": "Debug" } } @@ -162,10 +161,7 @@ Export CaeriusNet logs to an OTLP-compatible backend: ```csharp builder.Services.AddOpenTelemetry() - .WithLogging(logging => - { - logging.AddOtlpExporter(); - }); + .WithLogging(logging => logging.AddOtlpExporter()); builder.Services.AddLogging(logging => { @@ -189,37 +185,28 @@ builder.Services.AddLogging(logging => }); ``` -::: details Custom telemetry from CaeriusNet events -Use `ILogger` event subscriptions or middleware to convert CaeriusNet log events into custom Application Insights metrics: - -```csharp -// Example: track execution duration as a custom metric -services.AddSingleton(); -``` -::: - ## Performance considerations | Aspect | Guidance | |---|---| -| **Log level filtering** | Set `CaeriusNet.Cache` to `Warning` in production to suppress per-request cache hit/miss noise | -| **High-throughput paths** | `Debug` level events are compiled out when the level is not enabled (source-generator check) | -| **Structured sinks** | Prefer Seq, Elasticsearch, or OTLP over flat-file for queryability | +| **Log level filtering** | Set `CaeriusNet.Cache` to `Warning` in production to suppress per-call cache noise | +| **Hot paths** | `Debug`-level callsites are compiled out when the level is disabled (the source-generator emits an `IsEnabled` check) | +| **Structured sinks** | Prefer Seq, Elasticsearch, or OTLP over flat-file sinks for queryability | | **Sampling** | For very high RPS, configure sampling in your telemetry pipeline | ::: warning Avoid excessive logging in hot paths -`Debug`-level cache events fire on every database call. In production, ensure your minimum level is `Information` or higher for CaeriusNet categories to avoid log volume overwhelming sinks. +`Debug`-level cache events fire on every call. In production, ensure your minimum level is `Information` (or higher) for `CaeriusNet.Cache` to avoid log volume drowning your sinks. ::: ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| No CaeriusNet logs appear | Logger not configured | Call `LoggerProvider.SetLogger(...)` at startup | -| Missing structured parameters | Using flat-text sink | Switch to a structured sink (Seq, OTLP, JSON console) | -| High log volume | Debug level in production | Raise minimum level to Information | -| Missing timing data | Logs filtered too aggressively | Ensure event ID 5002 (command complete) is not filtered | +| No CaeriusNet logs appear | Logger not configured | Ensure `ILoggerFactory` is registered before `CaeriusNetBuilder.Build()` | +| Missing structured parameters | Flat-text sink in use | Switch to a structured sink (Seq, OTLP, JSON console) | +| High log volume | Debug level enabled in production | Raise the minimum level for `CaeriusNet.Cache` to `Warning` | +| Missing timing data | Event ID 5002 filtered out | Re-enable `CaeriusNet.Commands` at `Information` | --- -**Next:** [Best Practices](/documentation/best-practices) — recommendations for production-ready CaeriusNet usage. +**Next:** [Aspire Integration](/documentation/aspire) — connect CaeriusNet to the Aspire dashboard via OpenTelemetry. diff --git a/Documentations/docs/documentation/multi-results.md b/Documentations/docs/documentation/multi-results.md index b08402b..7d7072e 100644 --- a/Documentations/docs/documentation/multi-results.md +++ b/Documentations/docs/documentation/multi-results.md @@ -1,16 +1,16 @@ # Multiple Result Sets -SQL Server Stored Procedures can return more than one `SELECT` statement result. CaeriusNet exposes dedicated helpers that read up to five typed result sets from a single command execution — one round-trip, zero intermediate lists. +A SQL Server Stored Procedure can return more than one `SELECT` result. CaeriusNet exposes dedicated helpers that read up to **five typed result sets** from a single command execution — one round-trip, one connection, one telemetry span. ## When to use -Use multiple result sets when you need to fetch logically related data simultaneously: +Multiple result sets are the right tool whenever you need logically related data fetched together: -- A dashboard query returning users + orders + summary totals -- A paginated response returning a data page + a total count -- A reporting query combining header + line items in one call +- A **dashboard** query returning users + orders + summary totals +- A **paginated** response returning a data page + a total count +- A **report** combining header + line items in one call -Compared to multiple separate stored procedure calls, a single multi-result SP reduces round-trips, connection pool pressure, and total latency. +Compared to multiple separate SP calls, a single multi-result SP reduces round-trips, connection-pool pressure, and total latency — and it nests under a single span instead of fragmenting your trace. ## SQL Server setup @@ -22,98 +22,113 @@ BEGIN -- Result set 1: recent users SELECT Id, Username, Age - FROM dbo.Users + FROM dbo.Users ORDER BY Id DESC; -- Result set 2: recent orders SELECT OrderId, UserId, Total - FROM dbo.Orders + FROM dbo.Orders ORDER BY OrderId DESC; END +GO ``` ## Available overloads -CaeriusNet provides three collection-type families, each with overloads for 2 to 5 result sets: +CaeriusNet offers three collection-type families, each with overloads for **2 to 5** result sets. Pick the family that matches your storage, mutation, and allocation needs: ### `QueryMultipleIEnumerableAsync` -Returns a tuple of `IEnumerable` values: +Returns a tuple of `IEnumerable`: ```csharp Task<(IEnumerable, IEnumerable)> - QueryMultipleIEnumerableAsync(context, sp, ct) + QueryMultipleIEnumerableAsync(/* ... */); Task<(IEnumerable, IEnumerable, IEnumerable)> - QueryMultipleIEnumerableAsync(context, sp, ct) + QueryMultipleIEnumerableAsync(/* ... */); // ... up to T5 ``` ### `QueryMultipleReadOnlyCollectionAsync` -Returns a tuple of `ReadOnlyCollection` values: +Returns a tuple of `ReadOnlyCollection`: ```csharp Task<(ReadOnlyCollection, ReadOnlyCollection)> - QueryMultipleReadOnlyCollectionAsync(context, sp, ct) + QueryMultipleReadOnlyCollectionAsync(/* ... */); ``` ### `QueryMultipleImmutableArrayAsync` -Returns a tuple of `ImmutableArray` values: +Returns a tuple of `ImmutableArray`: ```csharp Task<(ImmutableArray, ImmutableArray)> - QueryMultipleImmutableArrayAsync(context, sp, ct) + QueryMultipleImmutableArrayAsync(/* ... */); ``` -## Example: two result sets +## Example — two result sets ```csharp -public sealed record UserRepository(ICaeriusNetDbContext DbContext) - : IUserRepository +public sealed record DashboardRepository(ICaeriusNetDbContext DbContext) + : IDashboardRepository { - public async Task<(IEnumerable, IEnumerable)> GetDashboardAsync( - CancellationToken cancellationToken) + public async Task<(IEnumerable Users, IEnumerable Orders)> + GetDashboardAsync(CancellationToken ct) { var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Dashboard_Data", 128) .Build(); - return await DbContext.QueryMultipleIEnumerableAsync( - sp, cancellationToken); + return await DbContext.QueryMultipleIEnumerableAsync(sp, ct); } } ``` -Destructure the tuple at the call site: +Destructure at the call site: ```csharp -var (users, orders) = await repository.GetDashboardAsync(cancellationToken); +var (users, orders) = await repository.GetDashboardAsync(ct); ``` -## Example: three result sets +## Example — three result sets ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Report_Data", 500) .AddParameter("Month", month, SqlDbType.TinyInt) .Build(); -var (users, orders, products) = await dbContext - .QueryMultipleIEnumerableAsync(sp, cancellationToken); +var (users, orders, products) = await DbContext + .QueryMultipleIEnumerableAsync(sp, ct); ``` -## Result set order +## Result-set order is the contract -The type parameters `T1`, `T2`, ... must match the **order** in which the SP returns its `SELECT` statements. The first type maps to the first result set, the second to the second, and so on. +The type parameters `T1`, `T2`, … must match the **order** in which the SP returns its `SELECT` statements. The first type maps to the first result set, the second to the second, and so on. There is no runtime name matching — sets are consumed sequentially from the `SqlDataReader`. ::: warning Order is strict -If the SP changes its SELECT order, update the type parameters accordingly. There is no runtime name matching — sets are consumed sequentially from the `SqlDataReader`. +If the SP changes its `SELECT` order, update the type parameters accordingly. Misaligning them produces an `InvalidCastException` (best case) or silently wrong values (worst case) at runtime. ::: ## DTO requirements -Each type parameter must be a class implementing `ISpMapper` (or generated with `[GenerateDto]`). The same ordinal-mapping rules apply as for single result sets. +Every type parameter must implement `ISpMapper` (manual or generated with `[GenerateDto]`). The same ordinal-mapping rules described in [DTO Mapping](/documentation/dto-mapping) apply per result set. + +## Telemetry + +Multi-result-set calls produce a single span tagged with: + +| Tag | Value | +|---|---| +| `caerius.resultset.multi` | `true` | +| `caerius.resultset.expected_count` | The number of result sets requested (2, 3, 4, or 5) | + +The single span keeps the trace cohesive — there is no fan-out into one span per `SELECT`. + +## Combining with TVPs and caching + +Multi-result-set calls accept the same builder features as single-result calls — TVPs, scalar parameters, and any of the cache tiers. See [Advanced Usage](/documentation/advanced-usage#multiple-result-sets-with-tvp) for combined examples. --- diff --git a/Documentations/docs/documentation/reading-data.md b/Documentations/docs/documentation/reading-data.md index 5d1ed1c..2fea166 100644 --- a/Documentations/docs/documentation/reading-data.md +++ b/Documentations/docs/documentation/reading-data.md @@ -1,21 +1,27 @@ # Reading Data -CaeriusNet provides four read methods, each returning a different collection type to match your performance and API requirements. All methods are `async`, accept a `CancellationToken`, and use `CommandBehavior.SequentialAccess` internally for efficient TDS streaming. +CaeriusNet exposes four read methods on `ICaeriusNetDbContext`, each returning a different collection type so you can match your performance, allocation, and API-shape requirements. All methods are `async`, accept a `CancellationToken`, and use `CommandBehavior.SequentialAccess` internally for efficient TDS streaming. ## Prerequisites -- A registered `ICaeriusNetDbContext` (via `CaeriusNetBuilder`) -- A `StoredProcedureParameters` built with `StoredProcedureParametersBuilder` -- A DTO implementing `ISpMapper` (or decorated with `[GenerateDto]`) +- A registered `ICaeriusNetDbContext` (via `CaeriusNetBuilder` — see [Installation & Setup](/quickstart/getting-started)) +- A `StoredProcedureParameters` value built with `StoredProcedureParametersBuilder` +- A DTO implementing `ISpMapper` (typically generated with `[GenerateDto]`) ## Choosing the right method | Method | Return type | When to use | |---|---|---| -| `QueryAsIEnumerableAsync` | `IEnumerable?` | Deferred access, LINQ pipelines | -| `QueryAsReadOnlyCollectionAsync` | `ReadOnlyCollection` | Public API surface, immutable contract | -| `QueryAsImmutableArrayAsync` | `ImmutableArray` | Struct-backed, allocation-efficient, frozen data | -| `FirstQueryAsync` | `T?` | Single-row lookups | +| `QueryAsIEnumerableAsync` | `IEnumerable?` | Deferred enumeration, LINQ pipelines | +| `QueryAsReadOnlyCollectionAsync` | `ReadOnlyCollection` | Public APIs that expose an immutable contract | +| `QueryAsImmutableArrayAsync` | `ImmutableArray` | Frozen, struct-backed, allocation-efficient data | +| `FirstQueryAsync` | `T?` | Single-row lookups (returns `null` when empty) | + +::: tip Allocation vs. ergonomics +- `IEnumerable` keeps the door open for downstream LINQ but exposes an enumerator allocation. +- `ReadOnlyCollection` materializes a `List` and wraps it — predictable, indexable, immutable. +- `ImmutableArray` is a struct over an array — best for cached, hot-path data passed by value. +::: ## Repository setup @@ -29,89 +35,86 @@ using System.Data; public sealed record UserRepository(ICaeriusNetDbContext DbContext) : IUserRepository { - // ... implementations below + // ... method implementations below } ``` ## `QueryAsIEnumerableAsync` -Returns `IEnumerable?` (null on empty result set). Best for LINQ-pipeline scenarios or when downstream code materializes the collection later. +Returns `IEnumerable?` (`null` on empty result set). Best for LINQ-pipeline scenarios or when downstream code materializes the collection later. ```csharp public async Task> GetUsersOlderThanAsync( - byte age, CancellationToken cancellationToken) + byte age, CancellationToken ct) { - var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Age", 450) + var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Age", capacity: 450) .AddParameter("Age", age, SqlDbType.TinyInt) .Build(); - return await DbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + return await DbContext.QueryAsIEnumerableAsync(sp, ct) ?? []; } ``` ## `QueryAsReadOnlyCollectionAsync` -Returns a `ReadOnlyCollection`. Ideal for public APIs where you want to expose an immutable-contract collection. +Returns a `ReadOnlyCollection` — empty when the SP returns no rows. Ideal for public APIs where the caller should see an immutable contract. ```csharp -public async Task> GetAllUsersAsync( - CancellationToken cancellationToken) +public async Task> GetAllUsersAsync(CancellationToken ct) { - var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250) + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) .AddFrozenCache("users:all:frozen") .Build(); - return await DbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken); + return await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct); } ``` ## `QueryAsImmutableArrayAsync` -Returns `ImmutableArray` — a struct wrapper over an array, ideal for frozen data sets that will be cached or passed around without mutation risk. +Returns `ImmutableArray` — a struct wrapper over an array. Ideal for frozen data sets that will be cached or passed around without mutation. ```csharp -public async Task> GetUsersImmutableAsync( - CancellationToken cancellationToken) +public async Task> GetUsersImmutableAsync(CancellationToken ct) { - var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250) + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) .Build(); - return await DbContext.QueryAsImmutableArrayAsync(sp, cancellationToken); + return await DbContext.QueryAsImmutableArrayAsync(sp, ct); } ``` ## `FirstQueryAsync` -Returns `T?` — reads only the first row and returns `null` if no rows are returned. Use for single-entity lookups. +Returns `T?` — reads only the first row and returns `null` if the result set is empty. Use it for single-entity lookups. ```csharp -public async Task GetUserByGuidAsync( - Guid guid, CancellationToken cancellationToken) +public async Task GetUserByGuidAsync(Guid guid, CancellationToken ct) { var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUser_By_Guid") .AddParameter("Guid", guid, SqlDbType.UniqueIdentifier) .Build(); - return await DbContext.FirstQueryAsync(sp, cancellationToken); + return await DbContext.FirstQueryAsync(sp, ct); } ``` -## Result set capacity +## Result-set capacity -The third argument to `StoredProcedureParametersBuilder` is `resultSetCapacity`. This pre-allocates the internal `List` to the expected row count, avoiding resizing: +The third constructor argument of `StoredProcedureParametersBuilder` is `resultSetCapacity`. It pre-allocates the internal `List` to the expected row count, avoiding reallocations as rows are added: ```csharp -// Expecting ~250 rows — pre-allocate to avoid List resizing -new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250) +// Expecting ~250 rows — pre-allocate to skip List resizing +new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", capacity: 250); ``` ::: tip Capacity tuning -Set capacity to a reasonable upper-bound estimate. Too low causes extra allocations; too high wastes memory. For write operations (no result set), the default `16` is fine. +Pick a reasonable upper bound. Too low triggers extra allocations; too high wastes memory. For write operations (no result set), the default `1` is fine — capacity is ignored. ::: -## CancellationToken +## `CancellationToken` -All read methods accept a `CancellationToken`. Pass it from your controller or caller to support request cancellation: +Every read method takes a `CancellationToken`. Propagate it from your controller, hosted service, or caller so the SQL command is cancelled if the request is aborted: ```csharp public async Task> GetAsync(CancellationToken ct) @@ -123,7 +126,7 @@ public async Task> GetAsync(CancellationToken ct) ## Caching reads -Add per-call caching to any read operation via the builder. See [Caching](/documentation/cache) for full details. +Add per-call caching to any read by chaining a cache method on the builder. See [Caching](/documentation/cache) for the full guide. ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250) @@ -131,6 +134,8 @@ var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250) .Build(); ``` +On a cache hit, **no SQL command is executed and no DB span is created** — only the `caerius.cache.lookups{hit=true}` counter ticks. + --- **Next:** [Writing Data](/documentation/writing-data) — execute INSERT, UPDATE, DELETE, and scalar returns. diff --git a/Documentations/docs/documentation/source-generators.md b/Documentations/docs/documentation/source-generators.md index 44132a9..1caad45 100644 --- a/Documentations/docs/documentation/source-generators.md +++ b/Documentations/docs/documentation/source-generators.md @@ -1,25 +1,25 @@ # Source Generators -CaeriusNet ships two Roslyn incremental source generators that eliminate mapping boilerplate at compile time. Both generators run as part of your build — they produce zero runtime overhead and are fully AOT-compatible. +CaeriusNet ships two Roslyn **incremental source generators** that eliminate mapping boilerplate at compile time. They run as part of your build, produce zero runtime overhead, and are fully AOT- and trim-compatible. ## Overview -| Generator | Attribute | Interface generated | +| Generator | Attribute | Generated interface | |---|---|---| | `DtoSourceGenerator` | `[GenerateDto]` | `ISpMapper` | | `TvpSourceGenerator` | `[GenerateTvp]` | `ITvpMapper` | -Both generators target **sealed partial records or classes**. The `partial` keyword lets the generator add the interface implementation as a second partial declaration alongside your type. +Both generators target **sealed partial records or classes** with a primary constructor. The `partial` keyword lets the generator add the interface implementation as a second declaration alongside your type. Constraints are enforced at compile time by the [CaeriusNet analyzer](/documentation/diagnostics) (`CAERIUS001`–`CAERIUS005`). ## `[GenerateDto]` — DTO mapper -Annotate a sealed partial record or class with `[GenerateDto]`. The generator emits a `MapFromDataReader` method with ordinal-based column reads, correct nullability guards, and special type conversions. +Annotate a sealed partial record (or class) with `[GenerateDto]`. The generator emits a `MapFromDataReader` method with ordinal-based column reads, correct nullability guards, and special-type conversions. ### Requirements - Type must be `sealed` - Type must be `partial` -- Type must use a primary constructor (the constructor parameters become the mapped columns, in order) +- Type must declare a **primary constructor** — its parameters become the mapped columns, in order. ### Basic example @@ -53,10 +53,10 @@ partial record UserDto : ISpMapper ``` ::: details Generated code attributes -- `[GeneratedCode]` marks the file as tool-generated for IDE and analysis tooling +- `[GeneratedCode]` marks the file as tool-generated for IDEs and analysis tooling - `[MethodImpl(AggressiveInlining)]` on `MapFromDataReader` enables JIT inlining of the hot-path mapper - `#pragma warning disable CS1591` suppresses XML doc warnings on generated code -- `#nullable enable` ensures nullable analysis applies to generated types +- `#nullable enable` ensures nullable analysis applies regardless of project-level settings ::: ### Nullable fields @@ -87,22 +87,72 @@ public static ProductDto MapFromDataReader(SqlDataReader reader) | `Half` | `(Half)reader.GetFloat(n)` | | `byte[]` | `reader.GetFieldValue(n)` | -::: tip Half type support -`System.Half` maps to SQL `real`. The generator reads via `GetFloat` and casts to `Half`. Use for low-precision floating-point values where memory is constrained. +::: tip `Half` support +`System.Half` maps to SQL `real`. The generator reads via `GetFloat` and casts to `Half`. Use it for low-precision floating-point values where memory matters. +::: + +### Ordinal constants for wide DTOs + +For DTOs with **more than 8 properties**, the generator emits named ordinal constants for readability: + +```csharp +[GenerateDto] +public sealed partial record LargeDto( + int Id, string Name, string Email, byte Age, + DateTime CreatedAt, DateTime? UpdatedAt, bool IsActive, + decimal Balance, string Country, string? Phone); +``` + +Generated (simplified): + +```csharp +partial record LargeDto : ISpMapper +{ + private const int OrdId = 0; + private const int OrdName = 1; + private const int OrdEmail = 2; + private const int OrdAge = 3; + private const int OrdCreatedAt = 4; + private const int OrdUpdatedAt = 5; + private const int OrdIsActive = 6; + private const int OrdBalance = 7; + private const int OrdCountry = 8; + private const int OrdPhone = 9; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static LargeDto MapFromDataReader(SqlDataReader reader) + => new( + reader.GetInt32(OrdId), + reader.GetString(OrdName), + reader.GetString(OrdEmail), + reader.GetByte(OrdAge), + reader.GetDateTime(OrdCreatedAt), + reader.IsDBNull(OrdUpdatedAt) ? null : reader.GetDateTime(OrdUpdatedAt), + reader.GetBoolean(OrdIsActive), + reader.GetDecimal(OrdBalance), + reader.GetString(OrdCountry), + reader.IsDBNull(OrdPhone) ? null : reader.GetString(OrdPhone)); +} +``` + +::: tip Why a threshold? +Compact DTOs read well with raw indices; wide ones do not. The 8-property threshold keeps simple cases terse and improves readability for wider result sets. ::: ## `[GenerateTvp]` — TVP mapper -Annotate a sealed partial record or class with `[GenerateTvp]`. The generator emits: -- `TvpTypeName` static property (e.g., `"dbo.tvp_int"`) -- `_tvpMetaData` static `SqlMetaData[]` field (one entry per property) -- `MapAsSqlDataRecords` iterator that **reuses a single `SqlDataRecord`** instance across all rows (zero-copy streaming) +Annotate a sealed partial record (or class) with `[GenerateTvp]` and provide the SQL schema and type name. The generator emits: + +- A static `TvpTypeName` property (e.g., `"dbo.tvp_int"`) +- A static `_tvpMetaData` `SqlMetaData[]` field — one entry per constructor parameter +- A `MapAsSqlDataRecords` iterator that **reuses a single `SqlDataRecord`** instance across all rows (zero-copy streaming) ### Requirements - Type must be `sealed` - Type must be `partial` -- Attribute requires `Schema` and `TvpName` named arguments +- Attribute requires **`Schema`** and **`TvpName`** named arguments +- `TvpName` cannot be empty (analyzer rule `CAERIUS004`) ### Basic example @@ -146,9 +196,13 @@ partial record UserIdTvp : ITvpMapper } ``` +::: tip Why a single reused `SqlDataRecord`? +`Microsoft.Data.SqlClient` synchronously consumes all column values before advancing to the next row, so overwriting the same record between `yield return` calls is safe — and it skips one allocation per row, which adds up at TVP sizes of 10 000 +. +::: + ### Nullable TVP fields -The generator emits `record.SetDBNull(n)` for nullable fields when the value is null: +The generator emits `record.SetDBNull(n)` for nullable fields whose value is null: ```csharp [GenerateTvp(Schema = "dbo", TvpName = "tvp_optional")] @@ -171,80 +225,32 @@ public sealed partial record UserIdTvp(int Id); |---|---|---| | `MapFromDataReader` | You write it | Compiler emits it | | Ordinal indices | You manage them | Auto-assigned from constructor order | -| Nullable guards | You add `IsDBNull` | Emitted when field is nullable | -| `SqlDataRecord` reuse | You implement it | Always reused (single instance) | -| Compilation | Compiles as-is | Requires `sealed partial` | -| Build-time errors | Runtime | Compile-time | +| Nullable guards | You add `IsDBNull` checks | Emitted when the parameter is nullable | +| `SqlDataRecord` reuse (TVP) | You implement it | Always reused (single instance) | +| Compilation requirement | Compiles as-is | Requires `sealed partial` + primary ctor | +| Contract violation feedback | Discovered at runtime | Reported at build time by the analyzer | ## Enabling the generators -The generators ship **inside the CaeriusNet NuGet package** as an embedded Roslyn analyzer. No additional package or MSBuild configuration is required. +The generators ship **inside the `CaeriusNet` NuGet package** as embedded Roslyn analyzers. No additional package, MSBuild target, or property is required. -```shell +```bash dotnet add package CaeriusNet ``` Once added, `[GenerateDto]` and `[GenerateTvp]` are available in the `CaeriusNet.Attributes.Dto` and `CaeriusNet.Attributes.Tvp` namespaces. ::: tip Inspecting generated code -Set `true` in your `.csproj` to write generated files to `obj/Generated/` for inspection. -::: - -## Ordinal constants - -For DTOs with many columns (more than 8), the generator emits named ordinal constants for readability: - -```csharp -[GenerateDto] -public sealed partial record LargeDto( - int Id, string Name, string Email, byte Age, - DateTime CreatedAt, DateTime? UpdatedAt, bool IsActive, - decimal Balance, string Country, string? Phone); -``` - -Generated: - -```csharp -partial record LargeDto : ISpMapper -{ - private const int OrdId = 0; - private const int OrdName = 1; - private const int OrdEmail = 2; - private const int OrdAge = 3; - private const int OrdCreatedAt = 4; - private const int OrdUpdatedAt = 5; - private const int OrdIsActive = 6; - private const int OrdBalance = 7; - private const int OrdCountry = 8; - private const int OrdPhone = 9; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static LargeDto MapFromDataReader(SqlDataReader reader) - => new( - reader.GetInt32(OrdId), - reader.GetString(OrdName), - reader.GetString(OrdEmail), - reader.GetByte(OrdAge), - reader.GetDateTime(OrdCreatedAt), - reader.IsDBNull(OrdUpdatedAt) ? null : reader.GetDateTime(OrdUpdatedAt), - reader.GetBoolean(OrdIsActive), - reader.GetDecimal(OrdBalance), - reader.GetString(OrdCountry), - reader.IsDBNull(OrdPhone) ? null : reader.GetString(OrdPhone)); -} -``` - -::: tip Threshold -The ordinal constant threshold (>8 properties) keeps simple DTOs compact while improving readability for wider result sets. +Set `true` in your `.csproj` to write generated files to `obj/Generated/` for inspection. Useful when debugging mapper behaviour or reviewing what the compiler produced. ::: -## Pragma directives +## Pragma directives in generated files All generated files include standard pragma directives: ```csharp // -#pragma warning disable CS1591 // Missing XML comment +#pragma warning disable CS1591 // Missing XML comment on a public member #nullable enable ``` @@ -253,4 +259,4 @@ All generated files include standard pragma directives: --- -**Next:** [Table-Valued Parameters](/documentation/tvp) — bulk inputs without DataTable overhead. +**Next:** [Table-Valued Parameters](/documentation/tvp) — bulk inputs without `DataTable` overhead. diff --git a/Documentations/docs/documentation/transactions.md b/Documentations/docs/documentation/transactions.md index 54279de..ee54b9f 100644 --- a/Documentations/docs/documentation/transactions.md +++ b/Documentations/docs/documentation/transactions.md @@ -1,129 +1,128 @@ -# Transactions +# Transactions -CaeriusNet provides a lightweight transaction scope that wraps `SqlTransaction` with a thread-safe state machine, automatic rollback on dispose, and cache bypass. Transactions execute multiple commands atomically — either all succeed or all are rolled back. +CaeriusNet provides a lightweight transaction scope that wraps `SqlTransaction` with a thread-safe state machine, automatic rollback on dispose, cache bypass, and a parent `TX` activity for cohesive tracing. Multiple commands enlisted on the scope succeed or fail **atomically** — there are no partial commits. -## Overview +## At a glance -| Feature | Behavior | +| Feature | Behaviour | |---|---| | **State machine** | `Active` → `Committed` / `RolledBack` / `Poisoned` / `Disposed` | -| **Thread safety** | `Interlocked.CompareExchange` for state transitions | -| **Command enforcement** | Single in-flight command (SqlConnection is not thread-safe) | -| **Failure handling** | Failure poisons the scope — only rollback/dispose remain valid | +| **Thread safety** | State transitions guarded by `Interlocked.CompareExchange` | +| **Single in-flight command** | Enforced — `SqlConnection` is not thread-safe | +| **Failure handling** | A failure poisons the scope; only `RollbackAsync` / `DisposeAsync` remain valid | | **Cache** | Bypassed inside transactions (no dirty reads published) | -| **Auto-rollback** | Uncommitted scope rolls back on `DisposeAsync` | +| **Auto-rollback** | An uncommitted scope rolls back on `DisposeAsync` | +| **Telemetry** | Parent `TX` span (kind = Internal) wraps every child SP span | | **Logging** | Structured events for start, commit, rollback, and poison | ## Basic usage ### Commit example -Execute multiple commands in a single transaction and commit atomically: +Execute multiple commands and commit atomically: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); var spInsert = new StoredProcedureParametersBuilder("dbo", "sp_InsertOrder") .AddParameter("UserId", userId, SqlDbType.Int) .AddParameter("Amount", amount, SqlDbType.Decimal) .Build(); -await tx.ExecuteNonQueryAsync(spInsert, cancellationToken); +await tx.ExecuteNonQueryAsync(spInsert, ct); var spUpdate = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserBalance") .AddParameter("UserId", userId, SqlDbType.Int) - .AddParameter("Debit", amount, SqlDbType.Decimal) + .AddParameter("Debit", amount, SqlDbType.Decimal) .Build(); -await tx.ExecuteNonQueryAsync(spUpdate, cancellationToken); +await tx.ExecuteNonQueryAsync(spUpdate, ct); -await tx.CommitAsync(cancellationToken); +await tx.CommitAsync(ct); ``` -### Rollback example +### Explicit rollback -Explicitly roll back when business logic requires it: +Roll back deliberately when business logic decides the work should not persist: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); var sp = new StoredProcedureParametersBuilder("dbo", "sp_ReserveInventory") .AddParameter("ProductId", productId, SqlDbType.Int) - .AddParameter("Quantity", quantity, SqlDbType.Int) + .AddParameter("Quantity", quantity, SqlDbType.Int) .Build(); -var reserved = await tx.ExecuteScalarAsync(sp, cancellationToken); +var reserved = await tx.ExecuteScalarAsync(sp, ct); if (reserved < quantity) { - await tx.RollbackAsync(cancellationToken); + await tx.RollbackAsync(ct); return InventoryResult.InsufficientStock; } -await tx.CommitAsync(cancellationToken); +await tx.CommitAsync(ct); return InventoryResult.Reserved; ``` ### Auto-rollback on dispose -If `CommitAsync` is never called, `DisposeAsync` automatically rolls back: +If `CommitAsync` is never called, `DisposeAsync` rolls back automatically: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); -await tx.ExecuteNonQueryAsync(sp, cancellationToken); +await tx.ExecuteNonQueryAsync(sp, ct); -// No CommitAsync — transaction is rolled back when tx is disposed. +// No CommitAsync — the transaction is rolled back when tx is disposed. ``` -::: warning Always call CommitAsync explicitly -Relying on auto-rollback is a safety net, not an intended control flow. Always call `CommitAsync` on the success path for clarity and intent. +::: warning Always call `CommitAsync` on the success path +Auto-rollback is a safety net, not an intended control flow. Calling `CommitAsync` explicitly makes the success path obvious to the reader and to logs. ::: -## Reading data inside transactions +## Reading inside a transaction -Query methods work inside a transaction scope. Results reflect uncommitted changes within the same transaction: +Query methods work inside a transaction scope. Reads see uncommitted changes made earlier in the same transaction: ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); var spInsert = new StoredProcedureParametersBuilder("dbo", "sp_InsertUser") .AddParameter("Username", "alice", SqlDbType.NVarChar) .Build(); -await tx.ExecuteNonQueryAsync(spInsert, cancellationToken); +await tx.ExecuteNonQueryAsync(spInsert, ct); var spRead = new StoredProcedureParametersBuilder("dbo", "sp_GetUser_By_Name", 1) .AddParameter("Username", "alice", SqlDbType.NVarChar) .Build(); -var user = await tx.QueryAsIEnumerableAsync(spRead, cancellationToken); -// user contains the uncommitted row — visible within this transaction. +var user = await tx.QueryAsIEnumerableAsync(spRead, ct); +// user contains the uncommitted row — visible within this transaction only. -await tx.CommitAsync(cancellationToken); +await tx.CommitAsync(ct); ``` ## State machine -The transaction scope enforces a strict state machine to prevent illegal operations: +The scope enforces a strict state machine to prevent illegal operations: -| State | Value | Allowed operations | -|---|---|---| -| **Active** | 0 | `ExecuteNonQueryAsync`, `QueryAs*Async`, `CommitAsync`, `RollbackAsync`, `DisposeAsync` | -| **Committed** | 1 | `DisposeAsync` only | -| **RolledBack** | 2 | `DisposeAsync` only | -| **Poisoned** | 3 | `RollbackAsync`, `DisposeAsync` only | -| **Disposed** | 4 | None — all calls throw `ObjectDisposedException` | - -### State transition diagram +| State | Allowed operations | +|---|---| +| **Active** | `ExecuteNonQueryAsync`, `QueryAs*Async`, `CommitAsync`, `RollbackAsync`, `DisposeAsync` | +| **Committed** | `DisposeAsync` only | +| **RolledBack** | `DisposeAsync` only | +| **Poisoned** | `RollbackAsync`, `DisposeAsync` only | +| **Disposed** | None — all calls throw `ObjectDisposedException` | -``` +```text ┌──────────────┐ - │ Active │ + │ Active │ └──────┬───────┘ │ ┌───────────┼───────────┐ @@ -139,27 +138,27 @@ Committed RolledBack Poisoned ### Poison state -When a command fails (throws an exception) inside an `Active` transaction, the scope transitions to `Poisoned`. In this state: +When a command fails inside an `Active` transaction, the scope transitions to `Poisoned`. In this state: - All subsequent `ExecuteNonQueryAsync` / `QueryAs*Async` calls throw immediately. - `CommitAsync` throws — you cannot commit a poisoned transaction. - Only `RollbackAsync` and `DisposeAsync` are valid. -This prevents partial commits after a failure — the entire unit of work must be retried. +This rule prevents partial commits after a failure. Retry the **entire** unit of work, do not attempt partial recovery. ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); try { - await tx.ExecuteNonQueryAsync(sp1, cancellationToken); // succeeds - await tx.ExecuteNonQueryAsync(sp2, cancellationToken); // throws — scope poisoned + await tx.ExecuteNonQueryAsync(sp1, ct); // succeeds + await tx.ExecuteNonQueryAsync(sp2, ct); // throws — scope is now Poisoned } catch (CaeriusNetSqlException) { - // tx is now Poisoned. CommitAsync would throw. - await tx.RollbackAsync(cancellationToken); + // CommitAsync would throw here. + await tx.RollbackAsync(ct); } ``` @@ -167,10 +166,10 @@ catch (CaeriusNetSqlException) ### Single in-flight command -`SqlConnection` is not thread-safe. CaeriusNet enforces a single command at a time within a transaction scope. Attempting concurrent commands throws `InvalidOperationException`: +`SqlConnection` is not thread-safe. CaeriusNet enforces a single command at a time within a scope. Concurrent commands throw `InvalidOperationException`: ```csharp -// ❌ DO NOT — concurrent commands on the same transaction +// ❌ DO NOT — concurrent commands on the same scope var task1 = tx.ExecuteNonQueryAsync(sp1, ct); var task2 = tx.ExecuteNonQueryAsync(sp2, ct); // throws InvalidOperationException await Task.WhenAll(task1, task2); @@ -184,46 +183,63 @@ await tx.ExecuteNonQueryAsync(sp2, ct); ### No nested transactions -SQL Server does not support nested transactions. Use SQL `SAVEPOINT` within a stored procedure if you need partial rollback semantics: +SQL Server does not support nested transactions on a single connection. Calling `BeginTransactionAsync` on a scope throws `NotSupportedException`. Use SQL `SAVEPOINT` inside a Stored Procedure if you need partial-rollback semantics: ```sql -CREATE PROCEDURE dbo.sp_WithSavepoint +CREATE PROCEDURE dbo.sp_With_Savepoint AS BEGIN SET NOCOUNT ON; - SAVE TRANSACTION SavePoint1; + SAVE TRANSACTION sp1; -- ... work ... IF @@ERROR <> 0 - ROLLBACK TRANSACTION SavePoint1; + ROLLBACK TRANSACTION sp1; END +GO ``` ### Cache bypass -CaeriusNet bypasses all cache layers (Frozen, InMemory, Redis) inside a transaction. This prevents uncommitted (dirty) data from being published to the cache and served to other consumers. +CaeriusNet bypasses every cache tier (Frozen, InMemory, Redis) inside a transaction scope. This prevents uncommitted data from being published to the cache and served to other consumers. After `CommitAsync`, subsequent non-transactional reads populate the cache normally. ::: tip Cache bypass is intentional -After `CommitAsync`, subsequent non-transactional reads will populate the cache normally. Design your cache keys and TTLs accordingly. +If a cached read is critical, perform it **before** entering the transaction, or **after** committing — never inside. ::: ### Failure poisoning -Any unhandled exception from a command execution poisons the transaction. This is deliberate — partial success in a transaction is unsafe. The poison state forces you to either: -1. Roll back explicitly with `RollbackAsync`. -2. Let `DisposeAsync` auto-rollback. +Any unhandled exception from a command poisons the scope. Partial success in a transaction is unsafe — you must either `RollbackAsync` explicitly or let `DisposeAsync` auto-rollback. + +## Tracing + +Every scope emits a parent **`TX` span** (kind = Internal) under which all child SP spans nest. The trace remains a single cohesive workflow in the Aspire dashboard: + +```text +TX (caerius.tx.isolation_level=ReadCommitted, caerius.tx.outcome=committed) +├── SP Users.usp_Create_User (caerius.tx=true) +└── SP Users.usp_Create_Order (caerius.tx=true) +``` + +| Tag | Description | +|---|---| +| `caerius.tx.isolation_level` | The SQL Server isolation level (e.g. `ReadCommitted`) | +| `caerius.tx.outcome` | `committed`, `rolled-back`, `auto-rollback`, `poisoned-auto-rollback`, `commit-failed`, `rollback-failed` | +| `caerius.tx` | `true` on every child SP span enlisted in the scope | + +See [Aspire Integration — Transaction tracing](/documentation/aspire#transaction-tracing) for full details. ## Best practices | Practice | Rationale | |---|---| -| Always use `await using` | Guarantees `DisposeAsync` runs even on exception paths | -| Pass `CancellationToken` | Cancels long-running SQL commands on request abort | -| Keep transactions short | Long transactions hold locks, block other readers/writers | -| Use appropriate `IsolationLevel` | `ReadCommitted` is the default; use `Serializable` only when required | -| Avoid I/O inside transactions | HTTP calls, file writes, etc. extend transaction lifetime unpredictably | -| Retry at the caller, not inside | If poisoned, create a new transaction scope for retry | - -::: warning Isolation level guidance +| Always use `await using` | Guarantees `DisposeAsync` runs on every code path, including exceptions | +| Pass `CancellationToken` | Cancels in-flight SQL commands when the request is aborted | +| Keep transactions short | Long transactions hold locks and block other readers / writers | +| Pick the right `IsolationLevel` | `ReadCommitted` is the default; raise it only when you must | +| Avoid I/O inside transactions | HTTP calls, file writes, etc. extend lifetime unpredictably | +| Retry at the caller, not inside | If poisoned, create a fresh scope and retry the whole unit of work | + +::: tip Isolation-level guidance - `ReadUncommitted` — dirty reads acceptable, highest concurrency - `ReadCommitted` — default, prevents dirty reads - `RepeatableRead` — prevents non-repeatable reads, higher lock contention @@ -233,40 +249,38 @@ Any unhandled exception from a command execution poisons the transaction. This i ## Error handling -All SQL errors inside a transaction are wrapped in `CaeriusNetSqlException`, which includes: +SQL errors raised inside a transaction are wrapped in `CaeriusNetSqlException`: -- The original `SqlException` as `InnerException` -- The stored procedure name that failed -- The transaction state at the time of failure +- Original `SqlException` is preserved as `InnerException` +- The active SP span is tagged `ActivityStatusCode.Error` before the exception bubbles up ```csharp -await using var tx = await dbContext.BeginTransactionAsync( - IsolationLevel.ReadCommitted, cancellationToken); +await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); try { - await tx.ExecuteNonQueryAsync(sp, cancellationToken); - await tx.CommitAsync(cancellationToken); + await tx.ExecuteNonQueryAsync(sp, ct); + await tx.CommitAsync(ct); } catch (CaeriusNetSqlException ex) when (ex.InnerException is SqlException sqlEx) { - // Log structured error details logger.LogError(ex, "Transaction failed on {Procedure}", ex.ProcedureName); - await tx.RollbackAsync(cancellationToken); + await tx.RollbackAsync(ct); } ``` ## Logging events -CaeriusNet emits structured log events for transaction lifecycle: +CaeriusNet emits structured log events for the transaction lifecycle: | Event | Level | Description | |---|---|---| -| Transaction started | Information | Logs isolation level and connection | +| Transaction started | Information | Logs isolation level and connection identifier | | Transaction committed | Information | Logs elapsed time | -| Transaction rolled back | Warning | Logs whether explicit or auto-rollback | -| Transaction poisoned | Error | Logs the causing exception | +| Transaction rolled back | Warning | Logs whether the rollback was explicit or automatic | +| Transaction poisoned | Error | Logs the originating exception | --- -**Next:** [Logging & Observability](/documentation/logging) — structured logging, event IDs, and telemetry integration. +**Next:** [Logging & Observability](/documentation/logging) — structured logging, event IDs, and OTel integration. diff --git a/Documentations/docs/documentation/tvp.md b/Documentations/docs/documentation/tvp.md index c11267d..2439ba3 100644 --- a/Documentations/docs/documentation/tvp.md +++ b/Documentations/docs/documentation/tvp.md @@ -1,16 +1,18 @@ # Table-Valued Parameters -Table-Valued Parameters (TVP) let you pass an entire set of rows — IDs, GUIDs, composite keys — as a single parameter to a SQL Server Stored Procedure. CaeriusNet implements TVP streaming via `IEnumerable`, which avoids `DataTable` allocation and streams data directly to the TDS protocol layer. +Table-Valued Parameters (TVP) let you pass an entire **set of rows** — IDs, GUIDs, composite keys, or wider row shapes — as a single typed parameter to a SQL Server Stored Procedure. CaeriusNet implements TVP transport via streaming `IEnumerable`, which avoids `DataTable` allocations and feeds rows directly to the TDS protocol layer. -## What is a TVP? +## Why TVPs? -A TVP is a SQL Server user-defined table type that can be passed as a read-only parameter (`READONLY`) to Stored Procedures. Instead of sending 1 000 IDs one by one, you send a single structured table with 1 000 rows in one round-trip. +A TVP is a SQL Server **user-defined table type** that you pass as a `READONLY` parameter. Instead of sending 1 000 IDs one by one (or padding a dynamic SQL `IN`-list), you send a single structured table with 1 000 rows in one round-trip. ```sql -- 1. Create the SQL Server type -CREATE TYPE dbo.tvp_int AS TABLE ( +CREATE TYPE dbo.tvp_int AS TABLE +( Id INT NOT NULL ); +GO -- 2. Use it in a Stored Procedure CREATE PROCEDURE dbo.sp_GetUsers_By_Tvp_Ids @@ -19,16 +21,17 @@ AS BEGIN SET NOCOUNT ON; SELECT Id, Username, Age - FROM dbo.Users - WHERE Id IN (SELECT Id FROM @Ids); + FROM dbo.Users + WHERE Id IN (SELECT Id FROM @Ids); END +GO ``` ## C# implementation ### Source-generated (recommended) -Annotate a sealed partial record with `[GenerateTvp]`. Provide the SQL schema and type name: +Annotate a sealed partial record with `[GenerateTvp]` and provide the SQL `Schema` and `TvpName`: ```csharp using CaeriusNet.Attributes.Tvp; @@ -37,7 +40,7 @@ using CaeriusNet.Attributes.Tvp; public sealed partial record UserIdTvp(int Id); ``` -The generator emits `ITvpMapper` with a zero-allocation `SqlDataRecord` streaming implementation. See [Source Generators](/documentation/source-generators) for details on the generated code. +The generator emits `ITvpMapper` with a zero-allocation `SqlDataRecord` streaming implementation. See [Source Generators](/documentation/source-generators#generatetvp-tvp-mapper) for the generated shape. ### Manual implementation @@ -55,58 +58,60 @@ public sealed record UserIdTvp(int Id) : ITvpMapper public IEnumerable MapAsSqlDataRecords(IEnumerable items) { var metaData = new[] { new SqlMetaData("Id", SqlDbType.Int) }; - var record = new SqlDataRecord(metaData); // single instance reused across all rows + var record = new SqlDataRecord(metaData); // single instance reused across all rows + foreach (var item in items) { record.SetInt32(0, item.Id); - yield return record; // Microsoft.Data.SqlClient reads values before advancing + yield return record; } } } ``` -::: tip Single-instance reuse -A single `SqlDataRecord` is created once and its values are overwritten before each `yield return`. `Microsoft.Data.SqlClient` reads all column values synchronously before moving to the next row, making this zero-copy pattern safe. +::: tip Why a single reused `SqlDataRecord`? +`Microsoft.Data.SqlClient` reads all column values synchronously before advancing to the next row, so overwriting the same instance between `yield return` calls is safe and avoids one allocation per row. ::: -## Using TVP with the builder +## Using a TVP with the builder Pass the TVP collection via `AddTvpParameter`: ```csharp var ids = users.Select(u => new UserIdTvp(u.Id)).ToList(); -var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", 1024) +var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", capacity: 1024) .AddTvpParameter("Ids", ids) .Build(); -var users = await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken); +var matchedUsers = await dbContext.QueryAsIEnumerableAsync(sp, ct); ``` ::: warning Empty collections throw -`AddTvpParameter` validates that the collection is non-empty. Passing an empty `IEnumerable` throws `ArgumentException`. Validate before calling: +`AddTvpParameter` requires a non-empty collection — SQL Server rejects empty TVPs. Validate before calling: ```csharp if (ids.Count == 0) return []; ``` ::: -## Combining TVP with regular parameters +## Combining a TVP with regular parameters -Mix `.AddTvpParameter()` and `.AddParameter()` freely. Order does not matter for SQL Server, but match the SP parameter names exactly: +`AddTvpParameter` and `AddParameter` mix freely. Order does not matter for SQL Server, but parameter names must match the SP definition exactly: ```sql CREATE PROCEDURE dbo.sp_GetUsers_By_Tvp_Ids_And_Age - @Ids dbo.tvp_int READONLY, - @Age INT + @Ids dbo.tvp_int READONLY, + @Age INT AS BEGIN SET NOCOUNT ON; SELECT Id, Username, Age - FROM dbo.Users - WHERE Id IN (SELECT Id FROM @Ids) - AND Age >= @Age; + FROM dbo.Users + WHERE Id IN (SELECT Id FROM @Ids) + AND Age >= @Age; END +GO ``` ```csharp @@ -116,14 +121,15 @@ var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And .Build(); ``` -## Multi-column TVP +## Multi-column TVPs -TVPs are not limited to a single column. Define as many columns as needed: +TVPs are not limited to a single column. Define as many columns as needed and the generator handles them all: ```sql -CREATE TYPE dbo.tvp_user_key AS TABLE ( - Id INT NOT NULL, - Guid UNIQUEIDENTIFIER NOT NULL +CREATE TYPE dbo.tvp_user_key AS TABLE +( + Id INT NOT NULL, + Guid UNIQUEIDENTIFIER NOT NULL ); ``` @@ -132,15 +138,25 @@ CREATE TYPE dbo.tvp_user_key AS TABLE ( public sealed partial record UserKeyTvp(int Id, Guid Guid); ``` -## Performance notes +## Performance characteristics | Aspect | Detail | |---|---| -| Allocation | Single `SqlDataRecord` allocated per call, reused across all rows | -| Protocol | TDS-native structured parameter — no XML serialization | +| Allocation | Single `SqlDataRecord` allocated per call, reused for every row | +| Protocol | TDS-native structured parameter — no XML, no JSON, no `DataTable` | | Throughput | Scales to tens of thousands of rows efficiently | -| `DataTable` | Not used — avoids boxing all values into `object[]` rows | -| Empty guard | `ArgumentException` prevents empty TVP (SQL Server requires ≥ 1 row) | +| Boxing | Strongly-typed `Set*` calls (`SetInt32`, `SetGuid`, …) — no `object[]` row materialization | +| Empty input | `ArgumentException` prevents an invalid call (SQL Server requires ≥ 1 row) | + +## Telemetry + +When a TVP is attached, the corresponding SP span is tagged accordingly: + +| Tag | Value | +|---|---| +| `caerius.tvp.used` | `true` | +| `caerius.tvp.type_name` | The TVP type name (e.g. `dbo.tvp_int`); comma-separated when several TVPs are used | +| `caerius.sp.parameters` | `[TVP]` is shown for the TVP entry — the row data is never inlined into the span, even when `CaptureParameterValues = true` | --- diff --git a/Documentations/docs/documentation/writing-data.md b/Documentations/docs/documentation/writing-data.md index 72aac2d..5f3ad57 100644 --- a/Documentations/docs/documentation/writing-data.md +++ b/Documentations/docs/documentation/writing-data.md @@ -1,136 +1,153 @@ # Writing Data -CaeriusNet provides three write commands for executing Stored Procedures that modify data. All are async-only and accept a `CancellationToken`. +CaeriusNet provides three write commands for executing Stored Procedures that modify data — all asynchronous, all `CancellationToken`-aware, and all pluggable into a transaction scope. -## Write commands overview +## Overview -| Method | Return | When to use | +| Method | Returns | When to use | |---|---|---| -| `ExecuteNonQueryAsync` | `int` (rows affected) | INSERT / UPDATE / DELETE — need row count | -| `ExecuteAsync` | `void` | Fire-and-forget writes — row count not needed | -| `ExecuteScalarAsync` | `T?` | SELECT scalar — SCOPE_IDENTITY, COUNT, etc. | +| `ExecuteNonQueryAsync` | `int` (rows affected) | INSERT / UPDATE / DELETE — when you need a row count | +| `ExecuteAsync` | *void* (`ValueTask`) | Fire-and-forget writes — row count not needed | +| `ExecuteScalarAsync` | `T?` | SELECT scalar — `SCOPE_IDENTITY`, `COUNT`, status code, etc. | -## Setting up a write Stored Procedure +## A representative write SP ```sql CREATE PROCEDURE dbo.sp_UpdateUserAge_By_Guid - @Guid UNIQUEIDENTIFIER, - @Age TINYINT + @Guid UNIQUEIDENTIFIER, + @Age TINYINT AS BEGIN SET NOCOUNT ON; - SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN TRY - BEGIN TRANSACTION - UPDATE dbo.Users - SET Age = @Age - WHERE Guid = @Guid; + BEGIN TRANSACTION; + + UPDATE dbo.Users + SET Age = @Age + WHERE Guid = @Guid; + IF @@TRANCOUNT > 0 COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; - END CATCH + THROW; -- re-raise so the SP span is correctly tagged Error + END CATCH; END +GO ``` -## `ExecuteNonQueryAsync` — row count +## `ExecuteNonQueryAsync` — affected rows -Use when you need to know how many rows were affected (e.g., to validate an update): +Use when you need to know how many rows were affected (for example to validate an update): ```csharp public async Task UpdateUserAgeAsync( - Guid guid, byte age, CancellationToken cancellationToken) + Guid guid, byte age, CancellationToken ct) { var sp = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserAge_By_Guid") .AddParameter("Guid", guid, SqlDbType.UniqueIdentifier) - .AddParameter("Age", age, SqlDbType.TinyInt) + .AddParameter("Age", age, SqlDbType.TinyInt) .Build(); - return await DbContext.ExecuteNonQueryAsync(sp, cancellationToken); + return await DbContext.ExecuteNonQueryAsync(sp, ct); } ``` ## `ExecuteAsync` — fire and forget -Use when the result count is irrelevant. Slightly leaner than `ExecuteNonQueryAsync`: +Use when the affected-row count is irrelevant. Slightly leaner than `ExecuteNonQueryAsync`: ```csharp -public async Task DeleteUserAsync(Guid guid, CancellationToken cancellationToken) +public async Task DeleteUserAsync(Guid guid, CancellationToken ct) { var sp = new StoredProcedureParametersBuilder("dbo", "sp_DeleteUser_By_Guid") .AddParameter("Guid", guid, SqlDbType.UniqueIdentifier) .Build(); - await DbContext.ExecuteAsync(sp, cancellationToken); + await DbContext.ExecuteAsync(sp, ct); } ``` ## `ExecuteScalarAsync` — scalar return -Use when the Stored Procedure returns a single scalar value — for example a new identity, a count, or a status code: +Use when the SP returns a single scalar value — a new identity, a count, a status code, etc. ```sql CREATE PROCEDURE dbo.sp_InsertUser_Return_Id - @Username NVARCHAR(100), - @Age TINYINT + @Username NVARCHAR(100), + @Age TINYINT AS BEGIN SET NOCOUNT ON; INSERT INTO dbo.Users (Username, Age) VALUES (@Username, @Age); - SELECT SCOPE_IDENTITY(); + SELECT CAST(SCOPE_IDENTITY() AS INT); END +GO ``` ```csharp public async Task InsertUserAsync( - string username, byte age, CancellationToken cancellationToken) + string username, byte age, CancellationToken ct) { var sp = new StoredProcedureParametersBuilder("dbo", "sp_InsertUser_Return_Id") .AddParameter("Username", username, SqlDbType.NVarChar) - .AddParameter("Age", age, SqlDbType.TinyInt) + .AddParameter("Age", age, SqlDbType.TinyInt) .Build(); - return await DbContext.ExecuteScalarAsync(sp, cancellationToken); + return await DbContext.ExecuteScalarAsync(sp, ct); } ``` ## Capacity for write operations -Write operations do not return a result set. You can omit the capacity argument (it defaults to `16`) or explicitly pass any small value — it has no performance impact for non-query operations: +Write commands do not return result sets, so the `resultSetCapacity` argument has no effect — leave it at the default: ```csharp -// Capacity is irrelevant for writes — default 16 is fine -new StoredProcedureParametersBuilder("dbo", "sp_DeleteUser_By_Guid") +new StoredProcedureParametersBuilder("dbo", "sp_DeleteUser_By_Guid"); // capacity ignored ``` ## Multiple parameters -Chain as many `.AddParameter()` calls as needed. Each call maps to a named SP parameter: +Chain as many `.AddParameter()` calls as needed. Each maps to a named SP parameter: ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_InsertAuditLog") - .AddParameter("UserId", userId, SqlDbType.Int) - .AddParameter("Action", action, SqlDbType.NVarChar) - .AddParameter("OccurredAt", DateTime.UtcNow, SqlDbType.DateTime2) + .AddParameter("UserId", userId, SqlDbType.Int) + .AddParameter("Action", action, SqlDbType.NVarChar) + .AddParameter("OccurredAt", DateTime.UtcNow, SqlDbType.DateTime2) .Build(); -await DbContext.ExecuteAsync(sp, cancellationToken); +await DbContext.ExecuteAsync(sp, ct); ``` -## Combining TVP with writes +## Combining a TVP with a write -You can use `AddTvpParameter` on write operations too — for bulk inserts or deletes by ID set: +`AddTvpParameter` works on write commands too — perfect for bulk inserts or deletes by ID set: ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_DeleteUsers_By_Ids") .AddTvpParameter("Ids", userIds) .Build(); -var deleted = await DbContext.ExecuteNonQueryAsync(sp, cancellationToken); +var deleted = await DbContext.ExecuteNonQueryAsync(sp, ct); +``` + +See [Table-Valued Parameters](/documentation/tvp) for the full TVP guide. + +## Writes inside a transaction + +All three commands are also exposed on `ICaeriusNetTransaction` for atomic multi-statement units of work. See [Transactions](/documentation/transactions). + +```csharp +await using var tx = await DbContext.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + +await tx.ExecuteNonQueryAsync(spDebit, ct); +await tx.ExecuteNonQueryAsync(spCredit, ct); +await tx.CommitAsync(ct); ``` --- diff --git a/Documentations/docs/examples/index.md b/Documentations/docs/examples/index.md new file mode 100644 index 0000000..873c6bf --- /dev/null +++ b/Documentations/docs/examples/index.md @@ -0,0 +1,31 @@ +# Examples + +End-to-end, production-shaped walkthroughs for CaeriusNet. Each page covers **one category** from the SQL Server objects to the C# repository, including the telemetry tags emitted along the way. + +| Page | Scope | +|---|---| +| [Stored Procedures](/examples/stored-procedures) | Basic reads, writes, cache tiers, error handling, return-type variants | +| [Table-Valued Parameters](/examples/tvp) | `tvp_int`, `tvp_guid`, composite-key TVPs, TVP combined with scalar writes | +| [Multi-Result Sets](/examples/multi-result-sets) | 2-set / 3-set reads, TVP + multi-RS in one round-trip | +| [Transactions](/examples/transactions) | Commit, C#-side rollback, SQL-side rollback (`BEGIN CATCH`), poison handling | + +## Conventions used in this section + +- **Repositories** inject `ICaeriusNetDbContext` via primary-constructor DI and expose intent-revealing async methods. +- **DTOs and TVPs** use `[GenerateDto]` / `[GenerateTvp]` — sealed partial records with primary constructors. +- **All examples** propagate `CancellationToken` and use `await using` for transaction scopes. +- **SQL snippets** are idempotent (`SET NOCOUNT ON`, explicit schema) and match the schema used by the runnable `Exemples/` projects in the repository. +- **Telemetry callouts** name the tags emitted by each scenario so you can validate them in the Aspire dashboard. + +## Running the examples + +The full schema and Stored Procedures are created by the `init.sql` script bundled with the runnable example projects: + +- `Exemples/Default/CaeriusNet.Exemples.Default.Console/` — traditional connection-string setup +- `Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/` — cloud-native Aspire orchestration (SQL Server + Redis containers, `init.sql` applied automatically via `WithCreationScript`) + +Both projects exercise the same `IUsersService` and demonstrate seven distinct scenarios — including caches, TVPs, multi-result-sets, and the three transaction outcomes (commit, C#-side rollback, SQL-side rollback). + +--- + +Pick a page from the table above to dive in. diff --git a/Documentations/docs/examples/multi-result-sets.md b/Documentations/docs/examples/multi-result-sets.md new file mode 100644 index 0000000..c93601f --- /dev/null +++ b/Documentations/docs/examples/multi-result-sets.md @@ -0,0 +1,153 @@ +# Multi-Result Sets + +A Stored Procedure can return more than one `SELECT` result. CaeriusNet maps each set to a separate DTO collection in a single round-trip — no extra queries, no manual `NextResultAsync` calls, and a single cohesive span in the trace. + +This page demonstrates two scenarios: a pure 3-set dashboard read, and a 2-set read driven by a TVP filter. + +## SQL Server objects + +```sql +-- 3-set: dashboard summary (users + orders + per-user stats) +CREATE PROCEDURE Users.usp_Get_Dashboard +AS +BEGIN + SET NOCOUNT ON; + + -- Set #1: users + SELECT UserId, UserGuid + FROM Users.Users + ORDER BY UserId; + + -- Set #2: orders + SELECT OrderId, UserId, Label, Amount, CreatedAt + FROM Users.Orders + ORDER BY OrderId; + + -- Set #3: per-user statistics + SELECT u.UserId, + u.UserName, + COUNT(o.OrderId) AS OrdersCount, + COALESCE(SUM(o.Amount), 0) AS TotalAmount + FROM Users.Users AS u + LEFT JOIN Users.Orders AS o ON o.UserId = u.UserId + GROUP BY u.UserId, u.UserName + ORDER BY u.UserId; +END +GO + +-- 2-set: users + their orders, filtered by a TVP of user IDs +CREATE PROCEDURE Users.usp_Get_Users_With_Orders_By_Tvp + @tvp Types.tvp_Int READONLY +AS +BEGIN + SET NOCOUNT ON; + + -- Set #1: matching users + SELECT u.UserId, u.UserGuid + FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserId = u.UserId + ORDER BY u.UserId; + + -- Set #2: their orders + SELECT o.OrderId, o.UserId, o.Label, o.Amount, o.CreatedAt + FROM Users.Orders AS o + INNER JOIN @tvp AS t ON t.UserId = o.UserId + ORDER BY o.OrderId; +END +GO +``` + +## DTO definitions + +```csharp +[GenerateDto] +public sealed partial record UserDto(int UserId, Guid UserGuid); + +[GenerateDto] +public sealed partial record OrderDto( + int OrderId, + int UserId, + string Label, + decimal Amount, + DateTime CreatedAt); + +[GenerateDto] +public sealed partial record UserStatsDto( + int UserId, + string UserName, + int OrdersCount, + decimal TotalAmount); +``` + +## 1. Three result sets — pure multi-RS + +A 3-tuple destructures the three sets directly at the call site. The DTO type at each position **must** match the columns of the corresponding `SELECT` (positional contract): + +```csharp +public async Task GetDashboardAsync(CancellationToken ct) +{ + var sp = new StoredProcedureParametersBuilder( + "Users", "usp_Get_Dashboard", capacity: 25) + .Build(); + + var (users, orders, stats) = await DbContext + .QueryMultipleReadOnlyCollectionAsync(sp, ct); + + return new DashboardSnapshot(users, orders, stats); +} +``` + +::: tip Telemetry tags +`caerius.resultset.multi = true` · `caerius.resultset.expected_count = 3` +::: + +## 2. Two result sets — TVP + multi-RS + +You can combine a TVP input with a multi-result-set output — still **one** round-trip and **one** span: + +```csharp +public async Task<(IReadOnlyCollection Users, IReadOnlyCollection Orders)> + GetUsersWithOrdersByTvpAsync( + IReadOnlyCollection userIds, + CancellationToken ct) +{ + if (userIds.Count == 0) return ([], []); + + IEnumerable tvp = userIds.Select(id => new UsersIntTvp(id)); + + var sp = new StoredProcedureParametersBuilder( + "Users", "usp_Get_Users_With_Orders_By_Tvp", capacity: 25) + .AddTvpParameter("tvp", tvp) + .Build(); + + var (users, orders) = await DbContext + .QueryMultipleReadOnlyCollectionAsync(sp, ct); + + return (users, orders); +} +``` + +::: tip Telemetry tags +`caerius.tvp.used = true` · `caerius.resultset.multi = true` · `caerius.resultset.expected_count = 2` +::: + +## Available overloads + +| Method | Sets | Return type | +|---|---|---| +| `QueryMultipleReadOnlyCollectionAsync` | 2 | `(ReadOnlyCollection, ReadOnlyCollection)` | +| `QueryMultipleReadOnlyCollectionAsync` | 3 | `(ReadOnlyCollection, …, ReadOnlyCollection)` | +| `QueryMultipleImmutableArrayAsync` | 2 | `(ImmutableArray, ImmutableArray)` | +| `QueryMultipleImmutableArrayAsync` | 3 | `(ImmutableArray, …, ImmutableArray)` | +| `QueryMultipleIEnumerableAsync` | 2 | `(IEnumerable, IEnumerable)` | +| `QueryMultipleIEnumerableAsync` | 3 | `(IEnumerable, …, IEnumerable)` | + +The `IEnumerable`, `ReadOnlyCollection`, and `ImmutableArray` families are all available with arities **2 → 5**. + +::: warning Result-set order is the contract +CaeriusNet maps result sets **positionally** — the first `SELECT` becomes `T1`, the second becomes `T2`, and so on. The DTO type passed at each position must match the columns of the corresponding `SELECT`. There is no runtime name-matching; misalignment produces an `InvalidCastException` (best case) or silently wrong values. +::: + +--- + +**Next:** [Transactions](/examples/transactions) — commit, C#-side rollback, and SQL-side rollback. diff --git a/Documentations/docs/examples/stored-procedures.md b/Documentations/docs/examples/stored-procedures.md new file mode 100644 index 0000000..d561c34 --- /dev/null +++ b/Documentations/docs/examples/stored-procedures.md @@ -0,0 +1,183 @@ +# Stored Procedures + +This page walks through calling Stored Procedures with CaeriusNet — from the simplest read to error handling with a graceful fallback. Every snippet uses the source generator so DTOs implement `ISpMapper` automatically. + +## SQL Server objects + +```sql +-- Schema and table (already created by init.sql) +-- CREATE SCHEMA Users; +-- CREATE TABLE Users.Users ( +-- UserId INT IDENTITY PRIMARY KEY, +-- UserGuid UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(), +-- UserName NVARCHAR(64) NOT NULL +-- ); + +-- Read all users +CREATE PROCEDURE Users.usp_Get_All_Users +AS +BEGIN + SET NOCOUNT ON; + SELECT UserId, UserGuid + FROM Users.Users + ORDER BY UserId; +END +GO + +-- Insert a user, return its identity +CREATE PROCEDURE Users.usp_Create_User + @UserName NVARCHAR(64) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @name NVARCHAR(64) = COALESCE(@UserName, CONCAT(N'demo-', NEWID())); + + INSERT INTO Users.Users (UserName) + VALUES (@name); + + SELECT CAST(SCOPE_IDENTITY() AS INT) AS UserId; +END +GO +``` + +## DTO + +```csharp +using CaeriusNet.Attributes.Dto; + +[GenerateDto] +public sealed partial record UserDto(int UserId, Guid UserGuid); +``` + +## Repository skeleton + +All scenarios on this page live in the same repository. It receives `ICaeriusNetDbContext` and an `ILogger` via primary-constructor DI: + +```csharp +using CaeriusNet.Abstractions; +using CaeriusNet.Builders; +using CaeriusNet.Exceptions; +using Microsoft.Extensions.Logging; +using System.Data; + +public sealed record UsersRepository( + ICaeriusNetDbContext DbContext, + ILogger Logger) + : IUsersRepository +{ + // ... methods follow ... +} +``` + +## 1. Basic read — no cache + +```csharp +public async Task> GetAllUsersAsync(CancellationToken ct) +{ + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", capacity: 25) + .Build(); + + return await DbContext.QueryAsIEnumerableAsync(sp, ct) ?? []; +} +``` + +::: tip Telemetry produced +Span: `SP Users.usp_Get_All_Users` (kind = Client, `db.system=mssql`, `caerius.rows_returned=N`). +::: + +## 2. Reads with cache tiers + +CaeriusNet supports three cache tiers. Add a single fluent call before `.Build()` to opt in: + +::: code-group +```csharp [Frozen] +// Frozen cache — immutable snapshot, lives until the process restarts. +var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .AddFrozenCache("users:all:frozen") + .Build(); + +return await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct); +``` +```csharp [InMemory (TTL)] +// In-memory cache — per-process, refreshed after TTL expires. +var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .AddInMemoryCache("users:all:memory", TimeSpan.FromMinutes(1)) + .Build(); + +return await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct); +``` +```csharp [Redis (distributed)] +// Redis distributed cache — shared across replicas, ideal for scale-out. +var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .AddRedisCache("users:all:redis", TimeSpan.FromMinutes(2)) + .Build(); + +return await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct); +``` +::: + +::: tip Cache hits skip the database +On a cache hit, **no SQL command runs and no DB span is created** — only the `caerius.cache.lookups{hit=true}` counter ticks. The Aspire dashboard accurately shows the database was not contacted. +::: + +## 3. Write — scalar return + +`ExecuteScalarAsync` executes the SP and returns the first column of the first row — perfect for `SCOPE_IDENTITY()`, counts, or status codes: + +```csharp +public async Task CreateUserAsync(string userName, CancellationToken ct) +{ + var sp = new StoredProcedureParametersBuilder("Users", "usp_Create_User") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .Build(); + + return await DbContext.ExecuteScalarAsync(sp, ct) ?? 0; +} +``` + +## 4. Error handling — graceful fallback + +Wrap calls in `try`/`catch` to degrade gracefully when a transient SQL error occurs. `CaeriusNetSqlException` always wraps the original `SqlException` in `InnerException`, and the active OTel span is already tagged `ActivityStatusCode.Error` before the exception bubbles up: + +```csharp +public async Task> GetAllUsersSafeAsync(CancellationToken ct) +{ + try + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .Build(); + + return await DbContext.QueryAsIEnumerableAsync(sp, ct) ?? []; + } + catch (CaeriusNetSqlException ex) + { + Logger.LogError(ex, "Failed to load users — returning an empty list."); + return []; + } +} +``` + +## 5. Return-type variants + +`StoredProcedureParametersBuilder` is independent of the return type. Choose the collection that fits your scenario: + +```csharp +// IEnumerable — lazy, forward-only, lowest allocation; null on empty. +var lazy = await DbContext.QueryAsIEnumerableAsync(sp, ct); + +// IReadOnlyCollection — materialized list, indexable, immutable contract. +var collection = await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct); + +// ImmutableArray — struct-wrapped array, ideal for cached / shared data. +var array = await DbContext.QueryAsImmutableArrayAsync(sp, ct); + +// T? — single-row lookup; null when the result set is empty. +var first = await DbContext.FirstQueryAsync(sp, ct); +``` + +See [Reading Data](/documentation/reading-data) for guidance on choosing between them. + +--- + +**Next:** [Table-Valued Parameters](/examples/tvp) — pass sets of identifiers into a SP without dynamic SQL. diff --git a/Documentations/docs/examples/transactions.md b/Documentations/docs/examples/transactions.md new file mode 100644 index 0000000..adbeb02 --- /dev/null +++ b/Documentations/docs/examples/transactions.md @@ -0,0 +1,224 @@ +# Transactions + +CaeriusNet wraps SQL Server transactions in an `ICaeriusNetTransaction` scope obtained from `BeginTransactionAsync`. Every command executed on the scope reuses the same connection and is enlisted in the same transaction. This page walks through the three transactional outcomes you will encounter in production: **commit**, **C#-side rollback**, and **SQL-side rollback** (when the SP wraps its own `BEGIN TRY / BEGIN CATCH`). + +## Tracing + +Every scope emits a parent **`TX` span** (kind = Internal) that wraps all child SP spans. The trace stays a single cohesive workflow in the Aspire dashboard: + +```text +TX (caerius.tx.isolation_level=ReadCommitted, caerius.tx.outcome=committed) +├── SP Users.usp_Create_User (caerius.tx=true) +└── SP Users.usp_Create_Order (caerius.tx=true) +``` + +Without the parent `TX` span, each child SP would appear as an orphaned trace. The TX activity is what stitches them into a single unit of work. + +## SQL Server objects + +```sql +-- Returns the new user's identity (used inside the multi-step transaction below) +CREATE PROCEDURE Users.usp_Create_User + @UserName NVARCHAR(64) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @name NVARCHAR(64) = COALESCE(@UserName, CONCAT(N'demo-', NEWID())); + + INSERT INTO Users.Users (UserName) + VALUES (@name); + + SELECT CAST(SCOPE_IDENTITY() AS INT) AS UserId; +END +GO + +-- Second write chained inside the same transaction +CREATE PROCEDURE Users.usp_Create_Order + @UserId INT, + @Label NVARCHAR(64), + @Amount DECIMAL(10, 2) +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO Users.Orders (UserId, Label, Amount) + VALUES (@UserId, @Label, @Amount); + + SELECT CAST(SCOPE_IDENTITY() AS INT) AS OrderId; +END +GO + +-- Self-contained transactional SP using BEGIN TRY / BEGIN CATCH +CREATE PROCEDURE Users.usp_Create_User_Tx_Safe + @UserName NVARCHAR(64), + @ForceFailure BIT = 0 +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + BEGIN TRY + BEGIN TRANSACTION; + + INSERT INTO Users.Users (UserName) + VALUES (@UserName); + + DECLARE @newUserId INT = CAST(SCOPE_IDENTITY() AS INT); + + IF @ForceFailure = 1 + THROW 50001, N'Forced failure — rolling back.', 1; + + INSERT INTO Users.Orders (UserId, Label, Amount) + VALUES (@newUserId, N'Welcome bonus', 0.00); + + COMMIT TRANSACTION; + SELECT @newUserId AS UserId; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 + ROLLBACK TRANSACTION; + THROW; -- re-raise so CaeriusNet tags the span as Error + END CATCH; +END +GO +``` + +## Scenario 1 — Commit + +Two writes are committed atomically. If anything fails before `CommitAsync`, `await using` disposes the scope and rolls back automatically. + +```csharp +public async Task CreateUserWithFirstOrderAsync( + string userName, + string orderLabel, + decimal amount, + CancellationToken ct) +{ + // Wrap in await using so the scope rolls back on any non-committed exit. + await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + + // First write — create the user + var createUser = new StoredProcedureParametersBuilder("Users", "usp_Create_User") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .Build(); + + var newUserId = await tx.ExecuteScalarAsync(createUser, ct); + + // Second write — create their first order, using the ID from the first call + var createOrder = new StoredProcedureParametersBuilder("Users", "usp_Create_Order") + .AddParameter("@UserId", newUserId, SqlDbType.Int) + .AddParameter("@Label", orderLabel, SqlDbType.NVarChar) + .AddParameter("@Amount", amount, SqlDbType.Decimal) + .Build(); + + await tx.ExecuteScalarAsync(createOrder, ct); + + // Commit only after every command succeeds + await tx.CommitAsync(ct); + + return newUserId is int id ? id : 0; +} +``` + +::: tip TX span outcome +`caerius.tx.outcome = committed` +::: + +## Scenario 2 — C#-side rollback + +The application decides to discard the work after inspecting business rules: + +```csharp +public async Task DemonstrateClientSideRollbackAsync( + string userName, + CancellationToken ct) +{ + await using var tx = await DbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + + var createUser = new StoredProcedureParametersBuilder("Users", "usp_Create_User") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .Build(); + + await tx.ExecuteScalarAsync(createUser, ct); + + // Imagine a business-rule check here decides we should not persist. + await tx.RollbackAsync(ct); + // Nothing is saved to the database. +} +``` + +::: tip TX span outcome +`caerius.tx.outcome = rolled-back` +::: + +::: tip Implicit rollback on disposal +If neither `CommitAsync` nor `RollbackAsync` is called and the `await using` scope exits (even due to an exception), the transaction rolls back automatically in `DisposeAsync`. The TX outcome is `auto-rollback` (clean exit) or `poisoned-auto-rollback` (a command had already failed). +::: + +## Scenario 3 — SQL-side rollback (`BEGIN CATCH`) + +The Stored Procedure handles its own transaction. When `@ForceFailure = 1`, it rolls back inside `BEGIN CATCH` and re-throws. CaeriusNet wraps the resulting `SqlException` as `CaeriusNetSqlException` and marks the SP span with `ActivityStatusCode.Error`: + +```csharp +public async Task DemonstrateServerSideRollbackAsync( + string userName, + CancellationToken ct) +{ + var sp = new StoredProcedureParametersBuilder("Users", "usp_Create_User_Tx_Safe") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .AddParameter("@ForceFailure", true, SqlDbType.Bit) + .Build(); + + // This call throws CaeriusNetSqlException because the SP re-raises. + // The span is tagged ActivityStatusCode.Error — this is expected. + await DbContext.ExecuteAsync(sp, ct); +} +``` + +Caller-side handling: + +```csharp +try +{ + await usersService.DemonstrateServerSideRollbackAsync("alice", ct); +} +catch (CaeriusNetSqlException ex) +{ + // InnerException is the original SqlException. + Logger.LogWarning( + ex, + "SQL-side rollback occurred: {Message}", + ex.InnerException?.Message); + + // Apply your fallback logic here (retry, alert, return a default …) +} +``` + +::: warning Error span in the dashboard +The `SP Users.usp_Create_User_Tx_Safe` trace appears in red (Error) in the Aspire **Traces** tab. This is **intentional** — the span accurately reflects that the SQL command failed. It is not a CaeriusNet defect. +::: + +## Commands available on `ICaeriusNetTransaction` + +The transaction scope exposes the same `Execute*` / `Query*` surface as `ICaeriusNetDbContext`, with all calls enlisted in the active transaction: + +| Method | Description | +|---|---| +| `ExecuteAsync` | Execute a SP and ignore the result | +| `ExecuteNonQueryAsync` | Execute a SP and return the affected-row count | +| `ExecuteScalarAsync` | Execute a SP and return the first column of the first row | +| `FirstQueryAsync` | Read a single row and map it to a DTO | +| `QueryAsIEnumerableAsync` | Read all rows into an `IEnumerable` | +| `QueryAsReadOnlyCollectionAsync` | Read all rows into a `ReadOnlyCollection` | +| `QueryAsImmutableArrayAsync` | Read all rows into an `ImmutableArray` | + +::: danger Nested transactions are not supported +Calling `BeginTransactionAsync` on an `ICaeriusNetTransaction` throws `NotSupportedException`. SQL Server only supports one local transaction per connection — use `SAVEPOINT` inside the SP for partial-rollback semantics. +::: + +--- + +**See also:** [Transactions guide](/documentation/transactions) for the full state-machine, telemetry, and best-practice reference. diff --git a/Documentations/docs/examples/tvp.md b/Documentations/docs/examples/tvp.md new file mode 100644 index 0000000..1204a81 --- /dev/null +++ b/Documentations/docs/examples/tvp.md @@ -0,0 +1,206 @@ +# Table-Valued Parameters + +A Table-Valued Parameter lets you pass an entire **set of rows** into a Stored Procedure as a single typed temporary table. This avoids dynamic SQL, IN-list size limits, and the overhead of multiple round-trips. + +This page walks through three TVP shapes — single-int, single-Guid, composite `(int, Guid)` — and a TVP combined with a scalar write. + +## SQL Server objects + +```sql +-- 1. Schema for user-defined types +IF SCHEMA_ID(N'Types') IS NULL + EXEC(N'CREATE SCHEMA Types AUTHORIZATION dbo;'); +GO + +-- 2. User-defined table types — one per column shape you need +CREATE TYPE Types.tvp_Int AS TABLE +( + UserId INT NOT NULL +); +GO + +CREATE TYPE Types.tvp_Guid AS TABLE +( + UserGuid UNIQUEIDENTIFIER NOT NULL +); +GO + +CREATE TYPE Types.tvp_IntGuid AS TABLE +( + UserId INT NOT NULL, + UserGuid UNIQUEIDENTIFIER NOT NULL +); +GO + +-- 3. Stored procedures that consume each TVP shape +CREATE PROCEDURE Users.usp_Get_Users_From_TvpInt + @tvp Types.tvp_Int READONLY +AS +BEGIN + SET NOCOUNT ON; + SELECT u.UserId, u.UserGuid + FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserId = u.UserId + ORDER BY u.UserId; +END +GO + +CREATE PROCEDURE Users.usp_Get_Users_From_TvpGuid + @tvp Types.tvp_Guid READONLY +AS +BEGIN + SET NOCOUNT ON; + SELECT u.UserId, u.UserGuid + FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserGuid = u.UserGuid + ORDER BY u.UserId; +END +GO + +CREATE PROCEDURE Users.usp_Get_Users_From_TvpIntGuid + @tvp Types.tvp_IntGuid READONLY +AS +BEGIN + SET NOCOUNT ON; + SELECT u.UserId, u.UserGuid + FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserId = u.UserId AND t.UserGuid = u.UserGuid + ORDER BY u.UserId; +END +GO +``` + +## C# TVP types — source-generated + +CaeriusNet generates the `ITvpMapper` implementation when you apply `[GenerateTvp]`. The constructor parameters must match the SQL UDT column order and types exactly: + +```csharp +using CaeriusNet.Attributes.Tvp; + +[GenerateTvp(Schema = "Types", TvpName = "tvp_Int")] +public sealed partial record UsersIntTvp(int UserId); + +[GenerateTvp(Schema = "Types", TvpName = "tvp_Guid")] +public sealed partial record UsersGuidTvp(Guid UserGuid); + +[GenerateTvp(Schema = "Types", TvpName = "tvp_IntGuid")] +public sealed partial record UsersIntGuidTvp(int UserId, Guid UserGuid); +``` + +## 1. TVP read — filter by integer IDs + +```csharp +public async Task> GetUsersByTvpIntAsync( + CancellationToken ct) +{ + IEnumerable ids = [new(1), new(2), new(3), new(4)]; + + var sp = new StoredProcedureParametersBuilder( + "Users", "usp_Get_Users_From_TvpInt", capacity: 5) + .AddTvpParameter("tvp", ids) // matches @tvp in the SP (no leading @) + .Build(); + + return await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct); +} +``` + +::: tip Telemetry tags +`caerius.tvp.used = true` · `caerius.tvp.type_name = Types.tvp_Int` +::: + +## 2. TVP read — filter by GUIDs + +```csharp +public async Task> GetUsersByTvpGuidAsync( + CancellationToken ct) +{ + IEnumerable guids = + [ + new(Guid.Parse("11111111-1111-1111-1111-111111111111")), + new(Guid.Parse("33333333-3333-3333-3333-333333333333")), + ]; + + var sp = new StoredProcedureParametersBuilder( + "Users", "usp_Get_Users_From_TvpGuid", capacity: 5) + .AddTvpParameter("tvp", guids) + .Build(); + + return await DbContext.QueryAsImmutableArrayAsync(sp, ct); +} +``` + +## 3. TVP read — composite `(int, Guid)` key filter + +```csharp +public async Task> GetUsersByTvpIntGuidAsync( + CancellationToken ct) +{ + IEnumerable pairs = + [ + new(1, Guid.Parse("11111111-1111-1111-1111-111111111111")), + new(2, Guid.Parse("22222222-2222-2222-2222-222222222222")), + ]; + + var sp = new StoredProcedureParametersBuilder( + "Users", "usp_Get_Users_From_TvpIntGuid", capacity: 5) + .AddTvpParameter("tvp", pairs) + .Build(); + + return await DbContext.QueryAsIEnumerableAsync(sp, ct) ?? []; +} +``` + +## 4. TVP combined with a scalar write + +You can mix TVP parameters with regular scalar parameters in the same call. Here the TVP carries the target user IDs and the scalars carry the order metadata: + +```sql +CREATE PROCEDURE Users.usp_Create_Orders_For_Users + @tvp Types.tvp_Int READONLY, + @Label NVARCHAR(64), + @Amount DECIMAL(10, 2) +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO Users.Orders (UserId, Label, Amount) + SELECT t.UserId, @Label, @Amount + FROM @tvp AS t; + + SELECT @@ROWCOUNT AS RowsInserted; +END +GO +``` + +```csharp +public async Task CreateBatchOrdersAsync( + IReadOnlyCollection userIds, + string label, + decimal amount, + CancellationToken ct) +{ + if (userIds.Count == 0) return 0; + + IEnumerable targetUserIds = userIds.Select(id => new UsersIntTvp(id)); + + var sp = new StoredProcedureParametersBuilder("Users", "usp_Create_Orders_For_Users") + .AddTvpParameter("tvp", targetUserIds) + .AddParameter ("@Label", label, SqlDbType.NVarChar) + .AddParameter ("@Amount", amount, SqlDbType.Decimal) + .Build(); + + return await DbContext.ExecuteScalarAsync(sp, ct) ?? 0; +} +``` + +## Notes & constraints + +- The TVP parameter name in `AddTvpParameter("tvp", …)` must match the SP `@tvp` parameter name **without** the leading `@`. +- `READONLY` is **required** on TVP parameters in SQL Server Stored Procedures. +- TVP rows are streamed via `IEnumerable` directly into the TDS protocol — no `DataTable`, no boxing. +- `caerius.sp.parameters` always shows `[TVP]` for the TVP entry, even when `CaptureParameterValues = true`. The actual rows are never inlined into a span. +- `AddTvpParameter` throws `ArgumentException` for an empty collection — SQL Server rejects empty TVPs. Validate before calling. + +--- + +**Next:** [Multi-Result Sets](/examples/multi-result-sets) — fetch multiple result sets in one round-trip. diff --git a/Documentations/docs/index.md b/Documentations/docs/index.md index 2b2ac9e..70996f3 100644 --- a/Documentations/docs/index.md +++ b/Documentations/docs/index.md @@ -3,8 +3,8 @@ layout: home hero: name: "CaeriusNet" - text: "SQL Server Stored Procedures → C# DTOs" - tagline: Compile-time safe mapping. No reflection. No DataTable. Just SQL Server + C#. + text: "SQL Server Stored Procedures to C# DTOs" + tagline: A focused, high-performance micro-ORM for C# 14 / .NET 10. Compile-time safe mapping, zero reflection, first-class observability. image: alt: CaeriusNet Logo actions: @@ -15,28 +15,37 @@ hero: text: Get Started link: /quickstart/getting-started - theme: alt - text: Usage - link: /documentation/usage + text: Examples + link: /examples/ - theme: alt text: GitHub link: https://github.com/CaeriusNET/CaeriusNet features: - icon: 🛠️ - title: Stored Procedures only - details: Data access lives in T/SQL Stored Procedures, results land in typed C# DTOs. CaeriusNet binds both sides with minimal boilerplate and DI-first design. + title: Stored Procedures, first and only + details: Data access lives in T-SQL where it belongs. CaeriusNet binds Stored Procedure result sets to typed C# DTOs with a fluent builder, DI-first design, and a deliberately small API surface — no LINQ translation, no change tracking, no surprises. + - icon: ⚡ + title: Compile-time mapping via Roslyn generators + details: [GenerateDto] and [GenerateTvp] emit ISpMapper<T> / ITvpMapper<T> at build time. A dedicated analyzer enforces the sealed partial + primary-constructor shape, so contract drift surfaces in your IDE — never at runtime. - icon: 🚀 - title: Ordinal mapping — no reflection - details: Allocation-aware, ordinal-indexed mapping via SqlDataReader. Pre-sized collections, SequentialAccess readers, and compile-time source generators. - - icon: 💪 - title: TVP + multi-result in one call - details: Stream thousands of IDs via Table-Valued Parameters, combine them with scalar parameters, and return up to 5 result sets in a single round-trip. - - icon: 🔄 - title: Async-only I/O - details: Every database call is async and CancellationToken-aware for throughput and thread-pool health. + title: Ordinal, allocation-aware reads + details: Columns are read by index — never by name — through SqlDataReader with SequentialAccess. Result lists are pre-sized via your declared capacity, populated through CollectionsMarshal, and pooled with ArrayPool<T> for ImmutableArray outputs. + - icon: 📦 + title: Table-Valued Parameters, zero-copy + details: Pass thousands of IDs, GUIDs, or composite keys in a single round-trip. The generator emits a streaming IEnumerable<SqlDataRecord> that reuses a single record instance across rows — no DataTable, no boxing, no IN-list size limits. + - icon: 🔀 + title: Multiple result sets in one call + details: Return up to five typed result sets from a single Stored Procedure execution. Helpers come in three collection flavours — IEnumerable, ReadOnlyCollection, ImmutableArray — destructured straight into a tuple at the call site. - icon: 🧊 - title: Per-call caching - details: Opt into Frozen (immutable), InMemory (TTL), or Redis (distributed) caching directly on the parameters builder — zero overhead when not used. + title: Three-tier opt-in caching + details: Pick the right tier per call directly on the parameters builder. Frozen for static reference data, InMemory for short-TTL hot paths, Redis for shared distributed cache — zero overhead when not used, zero allocation on cache hits. + - icon: 🔐 + title: Atomic transactions with safety nets + details: BeginTransactionAsync returns a thread-safe scope with a strict state machine (Active → Committed / RolledBack / Poisoned). Failures auto-poison and rollback on dispose; child SP spans nest under a parent TX activity for one cohesive trace per unit of work. + - icon: 📡 + title: OpenTelemetry & Aspire, native + details: A built-in ActivitySource and Meter emit OTel-compliant spans (db.system, db.operation, db.statement) and metrics (duration, executions, errors, cache lookups). WithAspireSqlServer / WithAspireRedis wire it into the Aspire dashboard in two lines. --- \ No newline at end of file + diff --git a/Documentations/docs/quickstart/getting-started.md b/Documentations/docs/quickstart/getting-started.md index 381d50f..e80b09f 100644 --- a/Documentations/docs/quickstart/getting-started.md +++ b/Documentations/docs/quickstart/getting-started.md @@ -9,19 +9,25 @@ next: # Installation & Setup +This page walks through installing the package, configuring CaeriusNet for your hosting model, and running your first query end-to-end. + ## Prerequisites -- .NET 10.0 SDK or higher -- SQL Server 2019 or higher -- C# 14 language version +- **.NET 10.0 SDK** or higher +- **C# 14** language version (default for .NET 10 projects) +- **SQL Server 2019** or higher (Developer / Standard / Enterprise / Express / Azure SQL) +- *(Optional)* **Redis** for distributed caching +- *(Optional)* **.NET Aspire 10+** for cloud-native development -## Install the package +## 1. Install the package ```bash dotnet add package CaeriusNet ``` -## Configure the connection string +The package ships the runtime library, the Roslyn source generators, and the analyzer in one bundle — no additional packages are required. + +## 2. Configure the connection string Add your SQL Server connection string to `appsettings.json`: @@ -33,13 +39,15 @@ Add your SQL Server connection string to `appsettings.json`: } ``` -::: details Connection string parameters -The parameters `Trusted_Connection=True;MultipleActiveResultSets=true;Encrypt=True;` are required by `Microsoft.Data.SqlClient`. +::: details Connection string options +The flags `Trusted_Connection=True;MultipleActiveResultSets=true;Encrypt=True;` are recommended by `Microsoft.Data.SqlClient`. -Visit [connectionstrings.com/sql-server](https://www.connectionstrings.com/sql-server/) to generate a connection string for your environment. +For Azure SQL, dev containers, or Docker, see [connectionstrings.com/sql-server](https://www.connectionstrings.com/sql-server/) for ready-made templates. ::: -## Register CaeriusNet +## 3. Register CaeriusNet + +Pick the host model that matches your project. All three flavours produce the same registration — only the entry point differs. ::: code-group ```csharp [ASP.NET Core / Generic Host] @@ -60,12 +68,12 @@ app.Run(); using CaeriusNet.Builders; var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); +builder.AddServiceDefaults(); // Aspire ServiceDefaults wires OpenTelemetry CaeriusNetBuilder .Create(builder) - .WithAspireSqlServer("sqlserver") - .WithAspireRedis() // optional — defaults to "redis" + .WithAspireSqlServer("sqlserver") // matches AppHost AddSqlServer name + .WithAspireRedis() // optional — defaults to "redis" .Build(); var app = builder.Build(); @@ -89,17 +97,21 @@ CaeriusNetBuilder ``` ::: -## Register your repositories +::: tip Aspire ServiceDefaults +If you use Aspire, register CaeriusNet's `ActivitySource` and `Meter` in your `ServiceDefaults` project so spans and metrics flow into the dashboard. See the [Aspire Integration](/documentation/aspire#tracing-telemetry) guide. +::: + +## 4. Register your repositories -Register your repositories in the DI container: +Inject `ICaeriusNetDbContext` into a repository and register the contract in DI: ```csharp builder.Services.AddScoped(); ``` -## Your first query +## 5. Your first query -### 1. Create a Stored Procedure +### a. Define the Stored Procedure ```sql CREATE PROCEDURE dbo.sp_GetUsers_By_Age @@ -108,12 +120,13 @@ AS BEGIN SET NOCOUNT ON; SELECT Id, Name, Age - FROM dbo.Users - WHERE Age >= @Age; + FROM dbo.Users + WHERE Age >= @Age; END +GO ``` -### 2. Define a DTO +### b. Define the DTO ```csharp using CaeriusNet.Attributes.Dto; @@ -122,7 +135,9 @@ using CaeriusNet.Attributes.Dto; public sealed partial record UserDto(int Id, string Name, byte Age); ``` -### 3. Implement a repository +`[GenerateDto]` instructs the compile-time source generator to emit `ISpMapper.MapFromDataReader` for you. The DTO must be `sealed`, `partial`, and use a primary constructor — the analyzer enforces this at build time (see [Compiler Diagnostics](/documentation/diagnostics)). + +### c. Implement the repository ```csharp using CaeriusNet.Abstractions; @@ -133,18 +148,18 @@ public sealed record UserRepository(ICaeriusNetDbContext DbContext) : IUserRepository { public async Task> GetUsersOlderThanAsync( - int age, CancellationToken cancellationToken) + int age, CancellationToken ct) { - var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Age", 128) + var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Age", capacity: 128) .AddParameter("Age", age, SqlDbType.Int) .Build(); - return await DbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + return await DbContext.QueryAsIEnumerableAsync(sp, ct) ?? []; } } ``` -### 4. Inject and call +### d. Inject and call ```csharp public sealed class UserService(IUserRepository repository) @@ -154,6 +169,20 @@ public sealed class UserService(IUserRepository repository) } ``` ---- +That's it — you have a fully typed, instrumented, allocation-aware Stored Procedure call. + +## What happens behind the scenes + +1. The builder produces an immutable `StoredProcedureParameters` value. +2. `QueryAsIEnumerableAsync` opens a connection via `ICaeriusNetDbContext`, starts an OTel `Activity`, and executes the SP with `CommandBehavior.SequentialAccess`. +3. The compile-time-generated `UserDto.MapFromDataReader` reads each row by ordinal — no reflection, no name lookups. +4. Results are materialised into a pre-sized list (capacity = 128) and returned to the caller. +5. The `Activity` records duration, row count, and SP metadata; the `Meter` increments executions and observes the duration histogram. + +## Next steps -**Next:** [DTO Mapping](/documentation/dto-mapping) — learn how ordinal-based mapping works. \ No newline at end of file +- [DTO Mapping](/documentation/dto-mapping) — understand ordinal mapping and nullability. +- [Source Generators](/documentation/source-generators) — explore `[GenerateDto]` and `[GenerateTvp]` in depth. +- [Reading Data](/documentation/reading-data) — choose between `IEnumerable`, `ReadOnlyCollection`, and `ImmutableArray`. +- [Caching](/documentation/cache) — add Frozen / InMemory / Redis caching to any call. +- [Examples](/examples/) — end-to-end walkthroughs. diff --git a/Documentations/docs/quickstart/what-is-caeriusnet.md b/Documentations/docs/quickstart/what-is-caeriusnet.md index 7d861cd..23a311a 100644 --- a/Documentations/docs/quickstart/what-is-caeriusnet.md +++ b/Documentations/docs/quickstart/what-is-caeriusnet.md @@ -1,86 +1,111 @@ -# What is Caerius.NET? +# What is CaeriusNet? -Caerius.NET is a focused, high‑performance Micro‑ORM for C# 14 / .NET 10 that turns SQL Server Stored Procedure results into strongly typed C# DTOs — in microseconds. It is built for teams who favor SQL Server and Stored Procedures for data access and want compile‑time safety, predictable performance, and a minimal API surface. +CaeriusNet is a focused, high-performance **micro-ORM** for **C# 14 / .NET 10** that turns SQL Server Stored Procedure result sets into strongly typed C# DTOs — in microseconds, with no reflection. -We are unapologetically specialized: Caerius.NET targets C# and Microsoft SQL Server. This clarity lets us optimize deeply for T/SQL, `SqlDataReader`, and SQL Server features such as Table‑Valued Parameters (TVP) and multi‑result sets. +It is unapologetically specialized: CaeriusNet targets **Microsoft SQL Server** and **Stored Procedures**. That clarity lets it optimize deeply for T-SQL, `SqlDataReader`, and SQL Server features such as Table-Valued Parameters and multi-result-sets. -## Why Caerius.NET? +## Why CaeriusNet? ### 1. Performance you can feel -Caerius.NET is engineered to keep the hot path hot: -- Ordinal, allocation‑aware mapping: DTOs implement `ISpMapper` (or are source‑generated), reading columns by index for zero name lookups. -- Compiler‑guided optimizations: extensive use of `MethodImplOptions.AggressiveOptimization`/`AggressiveInlining` on hot methods. -- Low‑level buffers and spans: - - `CollectionsMarshal.SetCount` + `CollectionsMarshal.AsSpan(list)` to add items with minimal overhead. - - `ArrayPool.Shared` to rent/return buffers and build `ImmutableArray` without extra copies. - - `CommandBehavior.SequentialAccess` on readers to stream rows efficiently. -- Right‑sized collections: you declare an expected capacity per call; Caerius.NET pre‑allocates to avoid list resizing. -- Caching tiers built‑in: Frozen (immutable in‑process), In‑Memory (expirable), and Redis (distributed) — enabled per call via the builder. -In short: no runtime reflection for mapping, no dynamic expression compilation on the hot path, and minimal allocations. +CaeriusNet is engineered to keep the hot path hot: -### 2. Source‑generated DTO and TVP mappers -- `[GenerateDto]` emits a compile‑time `ISpMapper` for your sealed partial records/classes. You get static, ordinal mapping with correct nullability and types. -- `[GenerateTvp]` emits an `ITvpMapper` so you can pass large sets as TVPs without writing boilerplate mapping code. +- **Ordinal, allocation-aware mapping.** DTOs implement `ISpMapper` (manual or source-generated) and read columns by index — zero name lookups, fewer string allocations. +- **Compiler-guided optimizations.** Hot mappers carry `[MethodImpl(AggressiveInlining)]`; TVP iterators carry `[MethodImpl(AggressiveOptimization)]`. +- **Low-level buffers and spans.** `CollectionsMarshal.SetCount` + `AsSpan(list)` populate result lists in place; `ArrayPool.Shared` rents/returns buffers for `ImmutableArray` without extra copies. +- **Streaming reads.** Every `SqlCommand` runs with `CommandBehavior.SequentialAccess` to consume rows directly off the TDS stream. +- **Right-sized collections.** You declare an expected capacity per call; CaeriusNet pre-allocates so `List` never resizes mid-fill. +- **Caching tiers built in.** Frozen (immutable in-process), InMemory (TTL), and Redis (distributed) — opt in per call via the builder. -Prefer manual control? You can still implement `ISpMapper` / `ITvpMapper` yourself. +In short: no runtime reflection, no expression-tree compilation on the hot path, and minimal allocations. + +### 2. Source-generated mappers + +- **`[GenerateDto]`** emits a compile-time `ISpMapper` for sealed partial records or classes. You get static, ordinal mapping with correct nullability and special-type conversions. +- **`[GenerateTvp]`** emits an `ITvpMapper` so you can pass large sets as Table-Valued Parameters without writing boilerplate. +- A dedicated **Roslyn analyzer** enforces the contract (`sealed partial`, primary constructor) — drift surfaces in your IDE, not at runtime. + +Prefer manual control? You can still implement `ISpMapper` / `ITvpMapper` by hand. ### 3. SQL Server expertise, not a kitchen sink -- Stored Procedures first. TVP, multi‑result sets, scalar reads, and write commands (`ExecuteNonQueryAsync`, `ExecuteScalarAsync`, fire‑and‑forget `ExecuteAsync`). -- Fluent parameter builder with TVP support: `StoredProcedureParametersBuilder` composes parameters and caching for each call. -- Works great in existing databases that already standardize on Stored Procedures. -### 4. Simplicity and developer experience -- Small API surface: one builder, one `DbContext` abstraction, and a handful of query/command methods. -- Async‑only I/O by design to avoid accidental thread pool starvation and to play well with any SP duration. -- First‑class DI and Aspire integration: configure SQL Server and Redis via `CaeriusNetBuilder` (manual or Aspire‑style). +- **Stored Procedures first.** TVP, multi-result-sets, scalar reads, and write commands (`ExecuteNonQueryAsync`, `ExecuteScalarAsync`, fire-and-forget `ExecuteAsync`). +- **Fluent builder.** `StoredProcedureParametersBuilder` composes parameters, TVPs, and caching for each call. +- **Atomic transactions.** `BeginTransactionAsync` provides a thread-safe scope with a strict state machine, automatic rollback on dispose, and a parent `TX` activity for cohesive tracing. +- **Works with the schema you already have.** No migrations, no shadow tables, no surprise columns. + +### 4. Developer experience + +- Small API surface: one builder, one `ICaeriusNetDbContext` abstraction, a handful of query and command methods. +- **Async-only I/O** by design — every call is `async` and `CancellationToken`-aware. +- **DI-first.** `CaeriusNetBuilder` integrates cleanly with `IServiceCollection` and `IHostApplicationBuilder`. +- **Aspire-native.** `WithAspireSqlServer` / `WithAspireRedis` resolve connection strings from the AppHost in two lines. ### 5. Reliability and observability -- Clear exception boundaries: SQL errors are wrapped in `CaeriusNetSqlException` with the original `SqlException` preserved. -- Structured logging hooks: plug your logger (e.g., `ILogger`) to trace connections, caching, and Redis activity. -- Unit‑tested core and deterministic mapping semantics. + +- **Clear exception boundaries.** SQL errors are wrapped in `CaeriusNetSqlException`; the original `SqlException` stays available via `InnerException`. +- **Built-in OpenTelemetry.** An `ActivitySource` and a `Meter` emit OTel-compliant spans (`db.system`, `db.operation`, `db.statement`, plus rich `caerius.*` tags) and metrics (duration histogram, executions / errors / cache-lookup counters). +- **Structured logging** via source-generated `[LoggerMessage]` — zero allocations on disabled levels, named placeholders for queryable backends. +- **AOT- and trim-compatible** (`IsAotCompatible=true`, `IsTrimmable=true`) — the library is ready for AOT-published deployments. ## Core capabilities at a glance -- Stored Procedure to DTO mapping with compile‑time safety (`ISpMapper` / `[GenerateDto]`). -- High‑throughput reads into `IEnumerable`, `ReadOnlyCollection`, or `ImmutableArray` — pick what fits your scenario. -- Multiple result sets in a single round‑trip (2 to 5 result sets helpers). -- TVP (Table‑Valued Parameters) support with `[GenerateTvp]` for bulk‑style inputs. -- Per‑call caching: Frozen, In‑Memory (TTL), or Redis distributed. -- Write commands: `ExecuteNonQueryAsync`, `ExecuteScalarAsync`, and `ExecuteAsync`. -- DI/Aspire friendly setup via `CaeriusNetBuilder`. - -## When should you use Caerius.NET? -Use Caerius.NET when: -- Your team embraces SQL Server Stored Procedures for reads and writes. -- Latency and allocation budget matter — you want predictable, fast mapping and minimal runtime overhead. + +- Stored Procedure → DTO mapping with compile-time safety (`ISpMapper` / `[GenerateDto]`). +- High-throughput reads into `IEnumerable`, `ReadOnlyCollection`, or `ImmutableArray` — pick what fits. +- Up to **five result sets** in a single round-trip. +- **TVPs** with `[GenerateTvp]` for bulk-style inputs. +- Per-call caching: **Frozen**, **InMemory** (TTL), **Redis** distributed. +- Write commands: `ExecuteNonQueryAsync`, `ExecuteScalarAsync`, `ExecuteAsync`. +- Atomic transactions with a parent `TX` span and a strict state machine. +- DI / Aspire-friendly setup via `CaeriusNetBuilder`. + +## When should you use CaeriusNet? + +Choose CaeriusNet when: + +- Your team owns the SQL schema and embraces Stored Procedures for reads and writes. +- Latency and allocation budget matter — you want predictable, fast mapping with minimal runtime overhead. - You need to push large parameter sets (IDs, GUIDs, composite keys) via TVPs. -- You operate in services where per‑call caching (including Redis) can offload the database. +- You operate in services where per-call caching can offload the database. - You prefer versioned SQL and stable DTOs over runtime query generation. -It may not be the best fit when you require ORM features such as change tracking, LINQ query translation, or multi‑database portability. +It may not be the best fit when you require ORM features such as change tracking, LINQ-to-SQL translation, or multi-database portability. ## How does it compare? -- EF Core: feature‑rich ORM with change tracking and LINQ translation. Caerius.NET is leaner, SP‑centric, and typically faster on read paths due to ordinal mapping and fewer allocations. -- Dapper: excellent general‑purpose micro‑ORM. Caerius.NET optimizes specifically for SQL Server Stored Procedures, adds compile‑time DTO/TVP generators, multi‑result helpers, and per‑call caching primitives. -Choose the tool that matches your architecture — Caerius.NET excels when SPs, TVPs, and throughput are central. +| | CaeriusNet | EF Core | Dapper | +|---|---|---|---| +| Target database | SQL Server only | SQL Server, PostgreSQL, MySQL, … | Any ADO.NET provider | +| Query model | Stored Procedures | LINQ → SQL translation | Raw SQL strings | +| Mapping | Compile-time, ordinal, no reflection | Runtime, expression-tree compilation | Runtime reflection or hand-rolled | +| Change tracking | None | Full | None | +| TVP support | Source-generated, zero-copy | Manual `DataTable` | Manual `DataTable` | +| Multi-result-set | Up to 5, typed tuple | Manual `ExecuteReader` + `NextResult` | `QueryMultiple` (typed by call) | +| Built-in caching | Frozen / InMemory / Redis | Second-level cache via extensions | None | +| OpenTelemetry | Built-in source + meter | Via instrumentation package | None | + +Pick the tool that matches your architecture — CaeriusNet excels when SPs, TVPs, and throughput are central. ## Architecture and hot path (simplified) + 1. You build call settings with `StoredProcedureParametersBuilder` (schema, SP name, capacity, parameters, optional cache). -2. Read commands open a connection via `ICaeriusNetDbContext.DbConnection()` and execute with `SqlCommand` using `SequentialAccess`. +2. Read commands open a connection via `ICaeriusNetDbContext` and execute with `SqlCommand` using `SequentialAccess`. 3. Rows are mapped by `ISpMapper` (manual or generated) with ordinal reads (e.g., `GetInt32(0)`). -4. Results are materialized using pre‑sized collections and pooling helpers; optional cache is read/written via chosen backend. +4. Results are materialized using pre-sized collections and pooling helpers; the optional cache is read or written via the chosen tier. +5. Throughout, an `Activity` records the SP execution and the `Meter` records duration, executions, errors, and cache lookups. + +## A taste — one SP, one cache, zero ceremony -## Example: one SP, one cache, zero fuss ```csharp var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250) .AddFrozenCache("users:all:frozen") .Build(); -var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken); +var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct); ``` -With TVP and Redis: +Same call, with a TVP filter and Redis caching: + ```csharp var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 1024) .AddTvpParameter("Ids", tvpItems) @@ -89,15 +114,18 @@ var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And .Build(); var (adults, seniors) = await dbContext - .QueryMultipleIEnumerableAsync(sp, cancellationToken); + .QueryMultipleIEnumerableAsync(sp, ct); ``` ## Benchmarks and realism -We publish micro‑benchmarks that focus on the mapping and materialization path. As always, measure in your environment — network, SQL plans, and payload size dominate end‑to‑end latency. Caerius.NET minimizes client‑side costs so your time is spent where it matters: on the database. - -## Learn more -- Quickstart: [Getting Started](/quickstart/getting-started) -- Deep dive: [Advanced Usage](/documentation/advanced-usage) -- Caching strategies: [Caching](/documentation/cache) -- Practices that scale: [Best Practices](/documentation/best-practices) -- Full surface: [API Reference](/documentation/api) \ No newline at end of file + +CaeriusNet publishes BenchmarkDotNet suites covering the mapping path, collection construction, TVP serialization, cache layers, and full SQL Server round-trips — all reproducible with a fixed seed (`Random(42)`) and run on every release. As always, **measure in your own environment**: network, SQL plans, and payload size dominate end-to-end latency. CaeriusNet minimizes client-side cost so your time is spent where it matters — on the database. + +## Next steps + +- [Installation & Setup](/quickstart/getting-started) — drop the package in, register DI, run your first query. +- [Source Generators](/documentation/source-generators) — let the compiler write your mappers. +- [Examples](/examples/) — end-to-end walkthroughs for SPs, TVPs, multi-result-sets, and transactions. +- [Aspire Integration](/documentation/aspire) — wire CaeriusNet into the Aspire dashboard. +- [Best Practices](/documentation/best-practices) — recommendations for production-ready usage. +- [API Reference](/documentation/api) — full public surface. diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs index a9365b1..fb15e2a 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs @@ -3,8 +3,17 @@ var sqlServer = builder.AddSqlServer("sqlserver") .WithDataVolume("sqlserver-data"); +// init.sql lives in Sql/init.sql (this project) so it is easy to find and edit. +// At runtime it is copied to the output directory alongside this binary. +var initSqlPath = Path.Combine(AppContext.BaseDirectory, "Sql", "init.sql"); +var initSqlScript = File.ReadAllText(initSqlPath); + +// Bootstrap the example schema/types/SPs as soon as the database is created. +// Aspire runs the script once when the container is provisioned (idempotent re-runs are +// handled by the script itself: every object is dropped before being re-created). var caeriusNet = sqlServer - .AddDatabase("CaeriusNet"); + .AddDatabase("CaeriusNet") + .WithCreationScript(initSqlScript); var redis = builder.AddRedis("redis"); diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/CaeriusNet.Exemples.Aspire.AppHost.csproj b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/CaeriusNet.Exemples.Aspire.AppHost.csproj index f514603..e7b33b6 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/CaeriusNet.Exemples.Aspire.AppHost.csproj +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/CaeriusNet.Exemples.Aspire.AppHost.csproj @@ -11,14 +11,24 @@ - - - - + + + + + + + + + + + diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/Sql/init.sql b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/Sql/init.sql new file mode 100644 index 0000000..acd6740 --- /dev/null +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/Sql/init.sql @@ -0,0 +1,329 @@ +/* +================================================================================ + CaeriusNet examples — schema bootstrap. +================================================================================ + This script creates everything the Default and Aspire console examples need: + + Schemas : Users, Types + Tables : Users.Users, Users.Orders + TVP types : Types.tvp_Int, Types.tvp_Guid, Types.tvp_IntGuid + Procedures : Users.usp_* (read / write, single + multi result-sets, + TVP + multi-RS, transactional create with + SQL-side rollback) + + Idempotent: every object is dropped before being (re)created. Safe to run + on a fresh database **or** to refresh an existing one. + + All inserts are intentionally limited to ~10 rows so the examples stay + readable when displayed on the console. +================================================================================ +*/ + +SET +NOCOUNT ON; +SET +XACT_ABORT ON; +GO + +------------------------------------------------------------ +-- 1. Drop dependent objects +------------------------------------------------------------ +IF OBJECT_ID(N'Users.usp_Get_Dashboard', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Get_Dashboard; +IF +OBJECT_ID(N'Users.usp_Get_Users_With_Orders_By_Tvp', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Get_Users_With_Orders_By_Tvp; +IF +OBJECT_ID(N'Users.usp_Create_User_Tx_Safe', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Create_User_Tx_Safe; +IF +OBJECT_ID(N'Users.usp_Create_Order', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Create_Order; +IF +OBJECT_ID(N'Users.usp_Create_User', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Create_User; +IF +OBJECT_ID(N'Users.usp_Get_Users_From_TvpInt', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Get_Users_From_TvpInt; +IF +OBJECT_ID(N'Users.usp_Get_Users_From_TvpGuid', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Get_Users_From_TvpGuid; +IF +OBJECT_ID(N'Users.usp_Get_Users_From_TvpIntGuid', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Get_Users_From_TvpIntGuid; +IF +OBJECT_ID(N'Users.usp_Get_All_Users', N'P') IS NOT NULL +DROP PROCEDURE Users.usp_Get_All_Users; + +IF +OBJECT_ID(N'Users.Orders', N'U') IS NOT NULL +DROP TABLE Users.Orders; +IF +OBJECT_ID(N'Users.Users', N'U') IS NOT NULL +DROP TABLE Users.Users; + +IF +TYPE_ID(N'Types.tvp_IntGuid') IS NOT NULL +DROP TYPE Types.tvp_IntGuid; +IF +TYPE_ID(N'Types.tvp_Guid') IS NOT NULL +DROP TYPE Types.tvp_Guid; +IF +TYPE_ID(N'Types.tvp_Int') IS NOT NULL +DROP TYPE Types.tvp_Int; +GO + +------------------------------------------------------------ +-- 2. Schemas +------------------------------------------------------------ +IF SCHEMA_ID(N'Users') IS NULL EXEC(N'CREATE SCHEMA Users AUTHORIZATION dbo;'); +IF +SCHEMA_ID(N'Types') IS NULL EXEC(N'CREATE SCHEMA Types AUTHORIZATION dbo;'); +GO + +------------------------------------------------------------ +-- 3. Tables +------------------------------------------------------------ +CREATE TABLE Users.Users +( + UserId INT IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Users_Users PRIMARY KEY, + UserGuid UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Users_Users_UserGuid DEFAULT NEWID(), + UserName NVARCHAR(64) NOT NULL, + CreatedAt DATETIME2(3) NOT NULL CONSTRAINT DF_Users_Users_CreatedAt DEFAULT SYSUTCDATETIME(), + CONSTRAINT UQ_Users_Users_UserGuid UNIQUE (UserGuid), + CONSTRAINT UQ_Users_Users_UserName UNIQUE (UserName) +); +GO + +CREATE TABLE Users.Orders +( + OrderId INT IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Users_Orders PRIMARY KEY, + UserId INT NOT NULL + CONSTRAINT FK_Users_Orders_Users REFERENCES Users.Users (UserId), + Label NVARCHAR(64) NOT NULL, + Amount DECIMAL(10, 2) NOT NULL, + CreatedAt DATETIME2(3) NOT NULL CONSTRAINT DF_Users_Orders_CreatedAt DEFAULT SYSUTCDATETIME() +); +GO + +------------------------------------------------------------ +-- 4. Table-Valued Parameter types +-- Names match the [GenerateTvp] attributes of the Commons project. +------------------------------------------------------------ +CREATE TYPE Types.tvp_Int AS TABLE + ( + UserId INT NOT NULL + ); +GO + +CREATE TYPE Types.tvp_Guid AS TABLE + ( + UserGuid UNIQUEIDENTIFIER NOT NULL + ); +GO + +CREATE TYPE Types.tvp_IntGuid AS TABLE + ( + UserId INT NOT NULL, + UserGuid UNIQUEIDENTIFIER NOT NULL + ); +GO + +------------------------------------------------------------ +-- 5. Seed data — small enough to fit on a console screen. +------------------------------------------------------------ +INSERT INTO Users.Users (UserGuid, UserName) +VALUES + ('11111111-1111-1111-1111-111111111111', N'alice'), + ('22222222-2222-2222-2222-222222222222', N'bob'), + ('33333333-3333-3333-3333-333333333333', N'carol'), + ('44444444-4444-4444-4444-444444444444', N'dave'), + ('55555555-5555-5555-5555-555555555555', N'erin'); + +INSERT INTO Users.Orders (UserId, Label, Amount) +VALUES (1, N'Coffee', 3.50), + (1, N'Croissant', 2.20), + (2, N'Notebook', 12.90), + (3, N'Pen', 1.75), + (3, N'Stickers', 4.00), + (5, N'Tea', 2.10); +GO + +------------------------------------------------------------ +-- 6. Single-result-set procedures +------------------------------------------------------------ +CREATE PROCEDURE Users.usp_Get_All_Users + AS +BEGIN + SET +NOCOUNT ON; +SELECT UserId, UserGuid +FROM Users.Users +ORDER BY UserId; +END; +GO + +CREATE PROCEDURE Users.usp_Get_Users_From_TvpInt @tvp Types.tvp_Int READONLY +AS +BEGIN + SET +NOCOUNT ON; +SELECT u.UserId, u.UserGuid +FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserId = u.UserId +ORDER BY u.UserId; +END; +GO + +CREATE PROCEDURE Users.usp_Get_Users_From_TvpGuid @tvp Types.tvp_Guid READONLY +AS +BEGIN + SET +NOCOUNT ON; +SELECT u.UserId, u.UserGuid +FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserGuid = u.UserGuid +ORDER BY u.UserId; +END; +GO + +CREATE PROCEDURE Users.usp_Get_Users_From_TvpIntGuid @tvp Types.tvp_IntGuid READONLY +AS +BEGIN + SET +NOCOUNT ON; +SELECT u.UserId, u.UserGuid +FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserId = u.UserId AND t.UserGuid = u.UserGuid +ORDER BY u.UserId; +END; +GO + +------------------------------------------------------------ +-- 7. Write procedures +------------------------------------------------------------ +CREATE PROCEDURE Users.usp_Create_User @UserName NVARCHAR(64) = NULL +AS +BEGIN + SET +NOCOUNT ON; + DECLARE +@name NVARCHAR(64) = COALESCE(@UserName, CONCAT(N'demo-', NEWID())); + +INSERT INTO Users.Users (UserName) +VALUES (@name); +SELECT CAST(SCOPE_IDENTITY() AS INT) AS UserId; +END; +GO + +CREATE PROCEDURE Users.usp_Create_Order @UserId INT, + @Label NVARCHAR(64), + @Amount DECIMAL(10, 2) +AS +BEGIN + SET +NOCOUNT ON; +INSERT INTO Users.Orders (UserId, Label, Amount) +VALUES (@UserId, @Label, @Amount); +SELECT CAST(SCOPE_IDENTITY() AS INT) AS OrderId; +END; +GO + +------------------------------------------------------------ +-- 8. Multi-result-set procedure +-- Returns three sets: users / orders / per-user totals. +-- Demonstrates the "caerius.resultset.multi = true" telemetry tag. +------------------------------------------------------------ +CREATE PROCEDURE Users.usp_Get_Dashboard + AS +BEGIN + SET +NOCOUNT ON; + + -- Set #1: users +SELECT UserId, UserGuid +FROM Users.Users +ORDER BY UserId; + +-- Set #2: orders +SELECT OrderId, UserId, Label, Amount, CreatedAt +FROM Users.Orders +ORDER BY OrderId; + +-- Set #3: per-user totals +SELECT u.UserId, + u.UserName, + COUNT(o.OrderId) AS OrdersCount, + COALESCE(SUM(o.Amount), 0) AS TotalAmount +FROM Users.Users AS u + LEFT JOIN Users.Orders AS o ON o.UserId = u.UserId +GROUP BY u.UserId, u.UserName +ORDER BY u.UserId; +END; +GO + +------------------------------------------------------------ +-- 9. TVP + multi-result-set in a single call. +-- Returns selected users (set #1) and their orders (set #2). +------------------------------------------------------------ +CREATE PROCEDURE Users.usp_Get_Users_With_Orders_By_Tvp @tvp Types.tvp_Int READONLY +AS +BEGIN + SET +NOCOUNT ON; + + -- Set #1: matching users +SELECT u.UserId, u.UserGuid +FROM Users.Users AS u + INNER JOIN @tvp AS t ON t.UserId = u.UserId +ORDER BY u.UserId; + +-- Set #2: their orders +SELECT o.OrderId, o.UserId, o.Label, o.Amount, o.CreatedAt +FROM Users.Orders AS o + INNER JOIN @tvp AS t ON t.UserId = o.UserId +ORDER BY o.OrderId; +END; +GO + +------------------------------------------------------------ +-- 10. Transaction-friendly procedure with SQL-side rollback. +-- Wraps the work in BEGIN TRY / BEGIN CATCH and re-raises so the +-- caller sees a CaeriusNetSqlException — mirroring the C#-side +-- rollback example in UsersRepository. +------------------------------------------------------------ +CREATE PROCEDURE Users.usp_Create_User_Tx_Safe @UserName NVARCHAR(64), + @ForceFailure BIT = 0 +AS +BEGIN + SET +NOCOUNT ON; + SET +XACT_ABORT ON; + +BEGIN TRY +BEGIN +TRANSACTION; + +INSERT INTO Users.Users (UserName) +VALUES (@UserName); +DECLARE +@newUserId INT = CAST(SCOPE_IDENTITY() AS INT); + + IF +@ForceFailure = 1 + THROW 50001, N'Forced failure inside usp_Create_User_Tx_Safe — rolling back.', 1; + +INSERT INTO Users.Orders (UserId, Label, Amount) +VALUES (@newUserId, N'Welcome bonus', 0.00); + +COMMIT TRANSACTION; + +SELECT @newUserId AS UserId; +END TRY +BEGIN CATCH +IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; +END CATCH; +END; +GO diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/CaeriusNet.Exemples.Aspire.Console.csproj b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/CaeriusNet.Exemples.Aspire.Console.csproj index 1f7b000..b1c14b9 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/CaeriusNet.Exemples.Aspire.Console.csproj +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/CaeriusNet.Exemples.Aspire.Console.csproj @@ -13,8 +13,8 @@ - + - + diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs new file mode 100644 index 0000000..e87c047 --- /dev/null +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs @@ -0,0 +1,42 @@ +namespace CaeriusNet.Exemples.Aspire.Console.DemoSections; + +/// +/// Demonstrates multi-result-set reads: a single stored-procedure call that returns +/// more than one result set, mapped to separate DTO collections in one round-trip. +/// +/// In the Aspire Traces tab each span shows +/// caerius.resultset.multi = true and caerius.resultset.expected_count +/// with the number of sets requested. +/// +/// +internal static class MultiResultSetDemo +{ + internal static async Task RunAsync(IUsersService users, CancellationToken ct) + { + // ── Pure multi-result-set (3 sets, 1 round-trip) ───────────────────── + // usp_Get_Dashboard returns: + // Set #1 — all users + // Set #2 — all orders + // Set #3 — per-user totals (aggregate statistics) + var dashboard = await users.GetDashboardAsync(ct); + System.Console.WriteLine( + $" ➤ Dashboard: {dashboard.Users.Count} users / " + + $"{dashboard.Orders.Count} orders / {dashboard.Stats.Count} stats rows"); + + foreach (var stats in dashboard.Stats) + System.Console.WriteLine( + $" {stats.UserName,-10} {stats.OrdersCount} orders, " + + $"total = {stats.TotalAmount:0.00}"); + + // ── TVP + multi-result-set (2 sets, filtered by TVP ids) ───────────── + // usp_Get_Users_With_Orders_By_Tvp receives ids {1, 3, 5} as a TVP and + // returns: + // Set #1 — matching users + // Set #2 — their orders + // One round-trip, one span, tags: caerius.tvp.used=true + resultset.multi=true + var (selectedUsers, theirOrders) = await users.GetUsersWithOrdersAsync([1, 3, 5], ct); + System.Console.WriteLine( + $" ➤ TVP+MultiRS: {selectedUsers.Count} users matched, " + + $"{theirOrders.Count} orders returned."); + } +} \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs new file mode 100644 index 0000000..d3e4960 --- /dev/null +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs @@ -0,0 +1,52 @@ +using CaeriusNet.Exceptions; + +namespace CaeriusNet.Exemples.Aspire.Console.DemoSections; + +/// +/// Demonstrates single-result-set reads with all three CaeriusNet cache tiers. +/// Run this section and inspect the Aspire Traces tab: +/// +/// First call to each SP creates a SP Users.usp_Get_All_Users span (db hit). +/// Subsequent Redis/Frozen/InMemory hits emit no DB span — only caerius.cache.hit=true. +/// +/// +internal static class ReadDemo +{ + internal static async Task RunAsync(IUsersService users, CancellationToken ct) + { + // ── No cache — always reaches the database ─────────────────────────── + Print("All users (no cache)", await users.GetAllUsersAsync(ct)); + + // ── Frozen cache — first call populates; second call is a cache hit ── + Print("All users (frozen cache, MISS)", await users.GetAllUsersWithFrozenCacheAsync(ct)); + Print("All users (frozen cache, HIT)", await users.GetAllUsersWithFrozenCacheAsync(ct)); + + // ── In-memory cache (1-minute TTL) ─────────────────────────────────── + Print("All users (in-memory cache)", await users.GetAllUsersWithMemoryCacheAsync(ct)); + + // ── Redis distributed cache — miss then hit ─────────────────────────── + Print("All users (Redis cache, MISS)", await users.GetAllUsersWithRedisCacheAsync(ct)); + Print("All users (Redis cache, HIT)", await users.GetAllUsersWithRedisCacheAsync(ct)); + + // ── Fallback pattern: handle a CaeriusNetSqlException gracefully ───── + // This shows how callers should guard against transient SQL failures + // without crashing the entire worker. We simulate the pattern using a + // successful call so the demo runs cleanly, but the try/catch is real. + try + { + var fallbackResult = await users.GetAllUsersAsync(ct); + Print("All users (fallback pattern, success path)", fallbackResult); + } + catch (CaeriusNetSqlException ex) + { + System.Console.WriteLine($" ➤ [fallback] SQL error — returning empty list. {ex.Message}"); + } + } + + private static void Print(string label, IEnumerable items) + { + System.Console.WriteLine($" ➤ {label}"); + foreach (var item in items) + System.Console.WriteLine($" {item}"); + } +} \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs new file mode 100644 index 0000000..5c6e9d3 --- /dev/null +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs @@ -0,0 +1,69 @@ +using CaeriusNet.Exceptions; + +namespace CaeriusNet.Exemples.Aspire.Console.DemoSections; + +/// +/// Demonstrates all three transaction scenarios offered by CaeriusNet. +/// +/// In the Aspire Traces tab, each scenario produces a single TX parent span +/// (kind = Internal) containing child SP Users.usp_* spans tagged with +/// caerius.tx = true. The parent span carries caerius.tx.isolation_level +/// and caerius.tx.outcome (committed / rolled-back / poisoned-auto-rollback). +/// +/// Scenarios: +/// +/// +/// Commit: two writes (create user + create order) committed atomically. +/// +/// +/// C#-side rollback: write followed by an explicit RollbackAsync +/// from application code — nothing is persisted. +/// +/// +/// SQL-side rollback (BEGIN CATCH): the stored procedure wraps its work +/// in a BEGIN TRY / BEGIN CATCH block and re-throws on forced failure. +/// CaeriusNet surfaces the exception as . +/// +/// +/// +internal static class TransactionDemo +{ + internal static async Task RunAsync(IUsersService users, CancellationToken ct) + { + // ── Scenario 1: commit ─────────────────────────────────────────────── + // Opens a ReadCommitted transaction, calls usp_Create_User then + // usp_Create_Order, then commits. Both SP spans are children of the + // TX span; outcome = "committed". + var newUserId = await users.CreateUserWithFirstOrderAsync( + $"aspire-{Guid.NewGuid():N}"[..24], + "First Aspire purchase", + 19.99m, + ct); + System.Console.WriteLine($" ➤ [commit] user #{newUserId} + first order persisted."); + + // ── Scenario 2: C#-side rollback ───────────────────────────────────── + // Opens a transaction, writes a user, then deliberately rolls back. + // The TX span outcome = "rolled-back"; the child SP span is still + // recorded (caerius.tx = true) but the database row is not saved. + await users.DemonstrateClientSideRollbackAsync( + $"rollback-cs-{Guid.NewGuid():N}"[..24], ct); + System.Console.WriteLine(" ➤ [C# rollback] transaction rolled back — nothing persisted."); + + // ── Scenario 3: SQL-side rollback (expected error span) ───────────── + // usp_Create_User_Tx_Safe is called with @ForceFailure=1. The stored + // procedure's BEGIN CATCH rolls back and re-throws. CaeriusNet wraps + // the SqlException as CaeriusNetSqlException and sets the SP span to + // ActivityStatusCode.Error. The calling code catches it gracefully. + try + { + await users.DemonstrateServerSideRollbackAsync( + $"rollback-sql-{Guid.NewGuid():N}"[..24], ct); + } + catch (CaeriusNetSqlException ex) + { + System.Console.WriteLine( + $" ➤ [SQL rollback] caught CaeriusNetSqlException (expected): " + + $"{ex.InnerException?.Message}"); + } + } +} \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs new file mode 100644 index 0000000..43091ab --- /dev/null +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs @@ -0,0 +1,44 @@ +namespace CaeriusNet.Exemples.Aspire.Console.DemoSections; + +/// +/// Demonstrates Table-Valued Parameter (TVP) driven reads. +/// A TVP lets you pass a set of values to a stored procedure as a typed temporary table, +/// avoiding dynamic SQL and IN-list limitations. +/// +/// In the Aspire Traces tab each span shows caerius.tvp.used = true and +/// caerius.tvp.type_name with the SQL Server user-defined table type name. +/// +/// +internal static class TvpReadDemo +{ + internal static async Task RunAsync(IUsersService users, CancellationToken ct) + { + // ── Types.tvp_Int — pass a list of integer user IDs ────────────────── + // Calls usp_Get_Users_From_TvpInt with ids {1, 2, 3, 4}. + Print("Filter by tvp_Int (ids 1-4)", + await users.GetUsersByTvpIntAsync(ct)); + + // ── Types.tvp_Guid — pass a list of GUID user identifiers ──────────── + // Calls usp_Get_Users_From_TvpGuid with 3 specific GUIDs. + PrintArray("Filter by tvp_Guid (3 GUIDs)", + await users.GetUsersByTvpGuidAsync(ct)); + + // ── Types.tvp_IntGuid — pass (int, Guid) composite key pairs ───────── + // Calls usp_Get_Users_From_TvpIntGuid — the SP joins on both columns, + // so only rows where BOTH parts match are returned. + Print("Filter by tvp_IntGuid (composite key pairs)", + await users.GetUsersByTvpIntGuidAsync(ct)); + } + + private static void Print(string label, IEnumerable items) + { + System.Console.WriteLine($" ➤ {label}"); + foreach (var item in items) + System.Console.WriteLine($" {item}"); + } + + private static void PrintArray(string label, IEnumerable items) + { + Print(label, items); + } +} \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs new file mode 100644 index 0000000..be129fd --- /dev/null +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs @@ -0,0 +1,60 @@ +using CaeriusNet.Exemples.Aspire.Console.DemoSections; + +namespace CaeriusNet.Exemples.Aspire.Console; + +/// +/// Orchestrates all CaeriusNet demo sections after the .NET generic host has fully started. +/// Executing inside a ensures the OTel +/// TelemetryHostedService has already registered its ActivityListener before any SQL +/// call is made, so CaeriusDiagnostics.ActivitySource.HasListeners() returns +/// and every span is exported to the Aspire dashboard. +/// +/// Demo sections are in the DemoSections/ folder — one file per concern. +/// +/// +internal sealed class DemoWorker( + IServiceScopeFactory scopeFactory, + IHostApplicationLifetime lifetime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + using var scope = scopeFactory.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService(); + + await Section("1. Single result-set + caches", + () => ReadDemo.RunAsync(users, stoppingToken)); + + await Section("2. TVP-driven reads", + () => TvpReadDemo.RunAsync(users, stoppingToken)); + + await Section("3. Multi result-set + TVP+MultiRS", + () => MultiResultSetDemo.RunAsync(users, stoppingToken)); + + await Section("4. Transactions (commit / C# rollback / SQL rollback)", + () => TransactionDemo.RunAsync(users, stoppingToken)); + + System.Console.WriteLine(); + System.Console.WriteLine("Examples completed. Inspect spans in the Aspire dashboard:"); + System.Console.WriteLine(" • Source : CaeriusNet"); + System.Console.WriteLine(" • Spans : SP Users.usp_* (kind=Client, db.system=mssql)"); + System.Console.WriteLine(" • TX : TX (kind=Internal, caerius.tx.outcome=committed|rolled-back)"); + System.Console.WriteLine( + " • Meter : caerius.sp.duration / caerius.sp.executions / caerius.sp.errors / caerius.cache.lookups"); + } + finally + { + lifetime.StopApplication(); + } + } + + private static async Task Section(string title, Func body) + { + System.Console.WriteLine(); + System.Console.WriteLine(new string('=', 76)); + System.Console.WriteLine($"== {title}"); + System.Console.WriteLine(new string('=', 76)); + await body(); + } +} \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs index 91f305f..76d91bc 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs @@ -3,6 +3,7 @@ global using CaeriusNet.Exemples.Libs.Commons.Abstractions; global using CaeriusNet.Exemples.Libs.Commons.Extensions; global using CaeriusNet.Logging; +global using CaeriusNet.Telemetry; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs index 8a89d1d..8d58707 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs @@ -1,26 +1,24 @@ -var builder = new HostApplicationBuilder(); +using CaeriusNet.Exemples.Aspire.Console; + +var builder = new HostApplicationBuilder(); builder.AddServiceDefaults(); CaeriusNetBuilder.Create(builder) .WithAspireSqlServer("CaeriusNet") .WithAspireRedis() + .WithTelemetryOptions(new CaeriusTelemetryOptions { CaptureParameterValues = true }) .Build(); builder.Services .AddDependenciesInjections() - .AddLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Debug)); + .AddLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Information)) + // DemoWorker runs after the host starts, ensuring the OTel TracerProvider is active + // before any SQL call is made (HasListeners() returns true → spans are exported). + .AddHostedService(); var app = builder.Build(); -var logger = app.Services.GetRequiredService>(); -LoggerProvider.SetLogger(logger); - -var usersRepository = app.Services.GetRequiredService(); - -var getAllUsers = await usersRepository.GetAllUsers(); - -var getAllUsersWithRedisCache1 = await usersRepository.GetAllUsersWithRedisCache(); -var getAllUsersWithRedisCache2 = await usersRepository.GetAllUsersWithRedisCache(); +LoggerProvider.SetLogger(app.Services.GetRequiredService>()); await app.RunAsync(); \ No newline at end of file diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/CaeriusNet.Exemples.Aspire.ServiceDefaults.csproj b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/CaeriusNet.Exemples.Aspire.ServiceDefaults.csproj index 4118655..4198d2e 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/CaeriusNet.Exemples.Aspire.ServiceDefaults.csproj +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/CaeriusNet.Exemples.Aspire.ServiceDefaults.csproj @@ -12,11 +12,11 @@ - - - - - + + + + + diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs index 6e5e530..d6520b9 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs @@ -68,11 +68,16 @@ private TBuilder ConfigureOpenTelemetry() { metrics.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); + .AddRuntimeInstrumentation() + // CaeriusNet metrics: caerius.sp.duration, caerius.sp.executions, + // caerius.sp.errors, caerius.cache.lookups. + .AddMeter("CaeriusNet"); }) .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) + // CaeriusNet stored procedure spans (kind = Client, db.system = mssql). + .AddSource("CaeriusNet") .AddAspNetCoreInstrumentation(tracing => // Exclude health check requests from tracing tracing.Filter = context => diff --git a/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs b/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs index 8d963ec..118a687 100644 --- a/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs +++ b/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs @@ -1,12 +1,19 @@ -var configuration = new ConfigurationBuilder() +using CaeriusNet.Exceptions; +using CaeriusNet.Exemples.Libs.Commons.Bootstrap; + +var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json") .Build(); var sqlConnectionString = configuration.GetConnectionString("DefaultConnection")!; +// Bootstrap the example database (idempotent — safe to call on every startup). +Console.WriteLine("Ensuring example database schema is up to date..."); +await ExampleDatabaseBootstrapper.EnsureCreatedAsync(sqlConnectionString); + var serviceCollection = new ServiceCollection() .AddDependenciesInjections() - .AddLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Debug)); + .AddLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Information)); CaeriusNetBuilder .Create(serviceCollection) @@ -19,11 +26,91 @@ var logger = serviceProvider.GetRequiredService>(); LoggerProvider.SetLogger(logger); -var usersRepository = serviceProvider.GetRequiredService(); +// Resolve IUsersService — the service layer wraps the repository and is the entry +// point for all business-logic interactions in this example. +var users = serviceProvider.GetRequiredService(); + +// --------------------------------------------------------------------------- +// Each scenario below produces telemetry under the "CaeriusNet" ActivitySource +// & Meter (see Documentations/docs/documentation/aspire.md). To capture it from +// a non-Aspire host, register an OpenTelemetry exporter against: +// • ActivitySource "CaeriusNet" +// • Meter "CaeriusNet" +// --------------------------------------------------------------------------- + +await DemoSection("1. Single result-set + caches", async () => +{ + Print("All users (no cache)", await users.GetAllUsersAsync()); + Print("All users (frozen cache, MISS)", await users.GetAllUsersWithFrozenCacheAsync()); + Print("All users (frozen cache, HIT)", await users.GetAllUsersWithFrozenCacheAsync()); + Print("All users (in-memory cache)", await users.GetAllUsersWithMemoryCacheAsync()); +}); + +await DemoSection("2. TVP-driven reads", async () => +{ + Print("Filter by Types.tvp_Int", await users.GetUsersByTvpIntAsync()); + Print("Filter by Types.tvp_Guid", await users.GetUsersByTvpGuidAsync()); + Print("Filter by Types.tvp_IntGuid", await users.GetUsersByTvpIntGuidAsync()); +}); + +await DemoSection("3. Multi result-set (caerius.resultset.multi = true)", async () => +{ + var dashboard = await users.GetDashboardAsync(); + Console.WriteLine( + $" ➤ {dashboard.Users.Count} users / {dashboard.Orders.Count} orders / {dashboard.Stats.Count} stats rows"); + foreach (var stats in dashboard.Stats) + Console.WriteLine($" {stats.UserName,-10} {stats.OrdersCount} orders, total = {stats.TotalAmount:0.00}"); +}); + +await DemoSection("4. TVP + multi result-set in a single call", async () => +{ + var (selectedUsers, theirOrders) = await users.GetUsersWithOrdersAsync([1, 3, 5]); + Console.WriteLine($" ➤ {selectedUsers.Count} users matched, with {theirOrders.Count} orders."); +}); + +await DemoSection("5. Transaction — commit (caerius.tx = true)", async () => +{ + var newUserId = await users.CreateUserWithFirstOrderAsync( + $"default-{Guid.NewGuid():N}"[..24], + "First default purchase", + 9.99m); + Console.WriteLine($" ➤ committed user #{newUserId}"); +}); + +await DemoSection("6. Transaction — C#-side rollback", async () => +{ + await users.DemonstrateClientSideRollbackAsync($"rollback-cs-{Guid.NewGuid():N}"[..24]); + Console.WriteLine(" ➤ rolled back from C# — nothing persisted."); +}); + +await DemoSection("7. Transaction — SQL-side rollback (BEGIN CATCH)", async () => +{ + try + { + await users.DemonstrateServerSideRollbackAsync($"rollback-sql-{Guid.NewGuid():N}"[..24]); + } + catch (CaeriusNetSqlException ex) + { + Console.WriteLine($" ➤ caught CaeriusNetSqlException: {ex.InnerException?.Message}"); + } +}); -var getAllUsers = await usersRepository.GetAllUsers(); +Console.WriteLine(); +Console.WriteLine("Examples completed. Press Enter to exit."); +Console.ReadLine(); -foreach (var user in getAllUsers) - Console.WriteLine(user); +static async Task DemoSection(string title, Func body) +{ + Console.WriteLine(); + Console.WriteLine(new string('=', 76)); + Console.WriteLine($"== {title}"); + Console.WriteLine(new string('=', 76)); + await body(); +} -Console.ReadLine(); \ No newline at end of file +static void Print(string label, IEnumerable items) +{ + Console.WriteLine($" ➤ {label}"); + foreach (var item in items) + Console.WriteLine($" {item}"); +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs new file mode 100644 index 0000000..d4bb4a5 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs @@ -0,0 +1,11 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Abstractions; + +/// +/// Snapshot returned by : a set of users, +/// a set of orders and a per-user statistics row produced by a single multi-result-set +/// stored procedure call. +/// +public sealed record DashboardSnapshot( + IReadOnlyCollection Users, + IReadOnlyCollection Orders, + IReadOnlyCollection Stats); \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs index edcc8f5..fb7cd54 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs @@ -2,12 +2,42 @@ public interface IUsersRepository { - Task> GetAllUsers(); - Task> GetAllUsersWithFrozenCache(); - Task> GetAllUsersWithMemoryCache(); - Task> GetAllUsersWithRedisCache(); - Task> GetUsersByTvpIntGuid(); - Task> GetUsersByTvpInt(); - Task> GetUsersByTvpGuid(); - Task CreateNewUser(); + // Reads --------------------------------------------------------------- + Task> GetAllUsers(CancellationToken cancellationToken = default); + Task> GetAllUsersWithFrozenCache(CancellationToken cancellationToken = default); + Task> GetAllUsersWithMemoryCache(CancellationToken cancellationToken = default); + Task> GetAllUsersWithRedisCache(CancellationToken cancellationToken = default); + Task> GetUsersByTvpIntGuid(CancellationToken cancellationToken = default); + Task> GetUsersByTvpInt(CancellationToken cancellationToken = default); + Task> GetUsersByTvpGuid(CancellationToken cancellationToken = default); + + // Writes -------------------------------------------------------------- + Task CreateNewUser(CancellationToken cancellationToken = default); + + // Multi-result sets --------------------------------------------------- + Task GetDashboardAsync(CancellationToken cancellationToken = default); + + Task<(IReadOnlyCollection Users, IReadOnlyCollection Orders)> + GetUsersWithOrdersByTvpAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken = default); + + // Transactions -------------------------------------------------------- + /// + /// Uses the C# transaction API: creates a user, creates one of their orders and + /// commits. Returns the new user identifier. + /// + Task CreateUserWithFirstOrderAsync(string userName, string firstOrderLabel, decimal amount, + CancellationToken cancellationToken = default); + + /// + /// C#-side rollback: opens a transaction, performs writes, then rolls back without + /// committing — nothing is persisted. + /// + Task DemonstrateClientSideRollbackAsync(string userName, CancellationToken cancellationToken = default); + + /// + /// SQL-side rollback: invokes Users.usp_Create_User_Tx_Safe with + /// @ForceFailure = 1. The stored procedure rolls back inside BEGIN CATCH + /// and re-throws — the call surfaces as a . + /// + Task DemonstrateServerSideRollbackAsync(string userName, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs new file mode 100644 index 0000000..d5b0507 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs @@ -0,0 +1,71 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Abstractions; + +/// +/// Business-logic entry point for user-related operations. +/// The service delegates to for data access and acts as +/// the boundary that domain/presentation layers depend on — keeping the repository +/// implementation hidden behind this interface. +/// +public interface IUsersService +{ + // ─── Reads ────────────────────────────────────────────────────────────── + + /// Retrieves all users without any cache. + Task> GetAllUsersAsync(CancellationToken cancellationToken = default); + + /// Retrieves all users; results are kept in frozen (immutable) cache after the first call. + Task> GetAllUsersWithFrozenCacheAsync(CancellationToken cancellationToken = default); + + /// Retrieves all users; results are kept in the in-process memory cache for 1 minute. + Task> GetAllUsersWithMemoryCacheAsync(CancellationToken cancellationToken = default); + + /// Retrieves all users; results are kept in Redis for 2 minutes. + Task> GetAllUsersWithRedisCacheAsync(CancellationToken cancellationToken = default); + + // ─── TVP-driven reads ──────────────────────────────────────────────────── + + /// Retrieves users matching a set of integer identifiers, passed as a Table-Valued Parameter. + Task> GetUsersByTvpIntAsync(CancellationToken cancellationToken = default); + + /// Retrieves users matching a set of GUID identifiers, passed as a Table-Valued Parameter. + Task> GetUsersByTvpGuidAsync(CancellationToken cancellationToken = default); + + /// Retrieves users matching a set of (int, Guid) pairs, passed as a Table-Valued Parameter. + Task> GetUsersByTvpIntGuidAsync(CancellationToken cancellationToken = default); + + // ─── Multi result-set reads ────────────────────────────────────────────── + + /// + /// Fetches users, orders and aggregate statistics in a single round-trip by calling a stored + /// procedure that returns three result sets. + /// + Task GetDashboardAsync(CancellationToken cancellationToken = default); + + /// + /// Fetches users and their orders for the specified identifiers in a single round-trip, + /// using a TVP to pass the identifiers and returning two result sets. + /// + Task<(IReadOnlyCollection Users, IReadOnlyCollection Orders)> + GetUsersWithOrdersAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken = default); + + // ─── Commands ──────────────────────────────────────────────────────────── + + /// + /// Creates a new user and attaches a first order inside a committed transaction. + /// Returns the new user's identifier. + /// + Task CreateUserWithFirstOrderAsync(string userName, string orderLabel, decimal amount, + CancellationToken cancellationToken = default); + + /// + /// Opens a transaction, writes a user, then rolls it back from the C# side — + /// demonstrating explicit client-controlled rollback. + /// + Task DemonstrateClientSideRollbackAsync(string userName, CancellationToken cancellationToken = default); + + /// + /// Calls a stored procedure that intentionally fails inside BEGIN CATCH and re-throws, + /// demonstrating SQL Server-side rollback surfaced as a . + /// + Task DemonstrateServerSideRollbackAsync(string userName, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs new file mode 100644 index 0000000..b584550 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs @@ -0,0 +1,77 @@ +using System.Text; +using Microsoft.Data.SqlClient; + +namespace CaeriusNet.Exemples.Libs.Commons.Bootstrap; + +/// +/// Idempotent SQL bootstrap that creates the schema, tables, TVP types, seed data and +/// stored procedures used by the CaeriusNet examples. The script lives at +/// Sql/init.sql and is embedded as a resource so callers don't need to ship it +/// separately. +/// +public static class ExampleDatabaseBootstrapper +{ + private const string ResourceName = "CaeriusNet.Exemples.Libs.Commons.Sql.init.sql"; + + /// + /// Returns the embedded init.sql script as plain text. Useful for callers that + /// want to hand the script over to Aspire's WithCreationScript. + /// + public static string GetCreationScript() + { + var assembly = typeof(ExampleDatabaseBootstrapper).Assembly; + using var stream = assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException( + $"Embedded resource '{ResourceName}' not found. Available: " + + string.Join(", ", assembly.GetManifestResourceNames())); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Connects to and applies the embedded + /// init.sql bootstrap script. Splits on top-level GO batch separators + /// because cannot execute them natively. + /// + public static async Task EnsureCreatedAsync(string connectionString, CancellationToken cancellationToken = default) + { + var script = GetCreationScript(); + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + foreach (var batch in SplitBatches(script)) + { + if (string.IsNullOrWhiteSpace(batch)) + continue; + + await using var command = connection.CreateCommand(); + command.CommandText = batch; + command.CommandTimeout = 120; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static IEnumerable SplitBatches(string script) + { + // Match a line that contains only "GO" (case-insensitive), optionally surrounded by whitespace. + var lines = script.Split('\n'); + var current = new StringBuilder(script.Length); + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + if (string.Equals(line.Trim(), "GO", StringComparison.OrdinalIgnoreCase)) + { + yield return current.ToString(); + current.Clear(); + continue; + } + + current.AppendLine(line); + } + + if (current.Length > 0) + yield return current.ToString(); + } +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/CaeriusNet.Exemples.Libs.Commons.csproj b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/CaeriusNet.Exemples.Libs.Commons.csproj index f48025f..5a5deba 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/CaeriusNet.Exemples.Libs.Commons.csproj +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/CaeriusNet.Exemples.Libs.Commons.csproj @@ -14,7 +14,15 @@ - + + + + + + diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs index d8c93e9..a572782 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs @@ -4,6 +4,8 @@ public static class ServiceCollectionExtension { public static IServiceCollection AddDependenciesInjections(this IServiceCollection services) { - return services.AddScoped(); + return services + .AddScoped() + .AddScoped(); } } \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs index c3633fd..8ba517f 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs @@ -3,10 +3,14 @@ global using CaeriusNet.Attributes.Tvp; global using CaeriusNet.Builders; global using CaeriusNet.Commands.Reads; +global using CaeriusNet.Commands.Transactions; global using CaeriusNet.Commands.Writes; +global using CaeriusNet.Exceptions; global using CaeriusNet.Exemples.Libs.Commons.Abstractions; global using CaeriusNet.Exemples.Libs.Commons.Models.Dtos; global using CaeriusNet.Exemples.Libs.Commons.Models.Tvps; global using CaeriusNet.Exemples.Libs.Commons.Repositories; +global using CaeriusNet.Exemples.Libs.Commons.Services; global using Microsoft.Extensions.DependencyInjection; -global using System.Collections.Immutable; \ No newline at end of file +global using System.Collections.Immutable; +global using System.Data; \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs new file mode 100644 index 0000000..5b5fdc4 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs @@ -0,0 +1,4 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Models.Dtos; + +[GenerateDto] +public sealed partial record OrderDto(int OrderId, int UserId, string Label, decimal Amount, DateTime CreatedAt); \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs new file mode 100644 index 0000000..f242efb --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs @@ -0,0 +1,4 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Models.Dtos; + +[GenerateDto] +public sealed partial record UserStatsDto(int UserId, string UserName, int OrdersCount, decimal TotalAmount); \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs new file mode 100644 index 0000000..c0b0dd5 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs @@ -0,0 +1,41 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Repositories; + +/// +/// Multi-result-set queries: a single stored-procedure call that returns more than one +/// result set. CaeriusNet maps each set to a separate DTO collection in one round-trip. +/// +public sealed partial class UsersRepository +{ + // ─── Pure multi-result-set: 3 sets in one round-trip ─────────────────── + + public async Task GetDashboardAsync(CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Dashboard", 25).Build(); + + var (users, orders, stats) = await dbContext + .QueryMultipleReadOnlyCollectionAsync(sp, cancellationToken) + .ConfigureAwait(false); + + return new DashboardSnapshot(users, orders, stats); + } + + // ─── TVP + multi-result-set: pass identifiers, get back 2 sets ───────── + + public async Task<(IReadOnlyCollection Users, IReadOnlyCollection Orders)> + GetUsersWithOrdersByTvpAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default) + { + var tvp = userIds.Select(id => new UsersIntTvp(id)); + + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_With_Orders_By_Tvp", 25) + .AddTvpParameter("tvp", tvp) + .Build(); + + var (users, orders) = await dbContext + .QueryMultipleReadOnlyCollectionAsync(sp, cancellationToken) + .ConfigureAwait(false); + + return (users, orders); + } +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs new file mode 100644 index 0000000..18804d1 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs @@ -0,0 +1,63 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Repositories; + +/// +/// Single-result-set read queries, with and without cache tiers. +/// These methods demonstrate the standard stored-procedure read pattern +/// and the three CaeriusNet cache layers: Frozen, InMemory and Redis. +/// +public sealed partial class UsersRepository +{ + // ─── No cache (always hits the database) ──────────────────────────────── + + public async Task> GetAllUsers(CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .Build(); + + return await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + } + + // ─── Frozen cache (immutable snapshot, lives for the process lifetime) ── + + public async Task> GetAllUsersWithFrozenCache(CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .AddFrozenCache("all_users_frozen") + .Build(); + + return await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + } + + // ─── In-memory cache (per-process, time-limited) ──────────────────────── + + public async Task> GetAllUsersWithMemoryCache(CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .AddInMemoryCache("all_users_memory", TimeSpan.FromMinutes(1)) + .Build(); + + return await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + } + + // ─── Redis distributed cache (shared across instances) ────────────────── + + public async Task> GetAllUsersWithRedisCache(CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) + .AddRedisCache("all_users_redis", TimeSpan.FromMinutes(2)) + .Build(); + + return await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + } + + // ─── Write ─────────────────────────────────────────────────────────────── + + public async Task CreateNewUser(CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Create_User") + .AddParameter("@UserName", $"demo-{Guid.NewGuid():N}"[..32], SqlDbType.NVarChar) + .Build(); + + await dbContext.ExecuteAsync(sp, cancellationToken); + } +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs new file mode 100644 index 0000000..643f106 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs @@ -0,0 +1,101 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Repositories; + +/// +/// Transactional write operations. +/// Each method demonstrates a distinct transaction scenario: +/// +/// +/// — two writes committed atomically +/// via the CaeriusNet transaction API. +/// +/// +/// — write followed by a +/// deliberate C#-side rollback (nothing is persisted). +/// +/// +/// — stored procedure that wraps +/// its own BEGIN TRY / BEGIN CATCH block and re-throws, surfaced as a +/// . +/// +/// +/// Every SP call made inside an ICaeriusNetTransaction scope is automatically tagged +/// with caerius.tx = true and appears as a child span of the parent TX activity +/// in the Aspire / OTel dashboard. +/// +public sealed partial class UsersRepository +{ + // ─── Scenario 1: two writes, committed atomically ─────────────────────── + + public async Task CreateUserWithFirstOrderAsync( + string userName, + string firstOrderLabel, + decimal amount, + CancellationToken cancellationToken = default) + { + await using var tx = await dbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken) + .ConfigureAwait(false); + + var createUser = new StoredProcedureParametersBuilder("Users", "usp_Create_User") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .Build(); + + var newUserId = await tx + .ExecuteScalarAsync(createUser, cancellationToken) + .ConfigureAwait(false); + + if (newUserId == 0) + throw new InvalidOperationException( + $"usp_Create_User returned invalid user identifier ({newUserId}); cannot create the associated order."); + + var createOrder = new StoredProcedureParametersBuilder("Users", "usp_Create_Order") + .AddParameter("@UserId", newUserId, SqlDbType.Int) + .AddParameter("@Label", firstOrderLabel, SqlDbType.NVarChar) + .AddParameter("@Amount", amount, SqlDbType.Decimal) + .Build(); + + await tx.ExecuteScalarAsync(createOrder, cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + + return newUserId; + } + + // ─── Scenario 2: C#-side rollback ─────────────────────────────────────── + + public async Task DemonstrateClientSideRollbackAsync( + string userName, + CancellationToken cancellationToken = default) + { + await using var tx = await dbContext + .BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken) + .ConfigureAwait(false); + + var createUser = new StoredProcedureParametersBuilder("Users", "usp_Create_User") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .Build(); + + // Write is performed but we deliberately decide not to keep it. + await tx.ExecuteScalarAsync(createUser, cancellationToken).ConfigureAwait(false); + + // Explicit rollback from the application layer — nothing is persisted. + // The parent TX span records outcome = "rolled-back". + await tx.RollbackAsync(cancellationToken).ConfigureAwait(false); + } + + // ─── Scenario 3: SQL-side rollback (BEGIN TRY / BEGIN CATCH) ──────────── + + public async Task DemonstrateServerSideRollbackAsync( + string userName, + CancellationToken cancellationToken = default) + { + var sp = new StoredProcedureParametersBuilder("Users", "usp_Create_User_Tx_Safe") + .AddParameter("@UserName", userName, SqlDbType.NVarChar) + .AddParameter("@ForceFailure", true, SqlDbType.Bit) + .Build(); + + // The stored procedure's BEGIN CATCH rolls back internally and re-throws. + // CaeriusNet wraps the SqlException as CaeriusNetSqlException, sets + // ActivityStatusCode.Error on the span and attaches the exception event. + await dbContext.ExecuteAsync(sp, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs new file mode 100644 index 0000000..ce53c96 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs @@ -0,0 +1,59 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Repositories; + +/// +/// TVP-driven read queries. +/// These methods pass a set of identifiers to SQL Server as a Table-Valued Parameter, +/// letting the engine filter inside the stored procedure instead of using an IN-list. +/// The three TVP types mirror the Types.tvp_* user-defined table types in the database. +/// +public sealed partial class UsersRepository +{ + // ─── Types.tvp_Int — filter by a set of integer user IDs ─────────────── + + public async Task> GetUsersByTvpInt(CancellationToken cancellationToken = default) + { + IEnumerable ids = [new(1), new(2), new(3), new(4)]; + + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_From_TvpInt", 5) + .AddTvpParameter("tvp", ids) + .Build(); + + return await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken); + } + + // ─── Types.tvp_Guid — filter by a set of GUID user identifiers ───────── + + public async Task> GetUsersByTvpGuid(CancellationToken cancellationToken = default) + { + IEnumerable guids = + [ + new(Guid.Parse("11111111-1111-1111-1111-111111111111")), + new(Guid.Parse("33333333-3333-3333-3333-333333333333")), + new(Guid.Parse("55555555-5555-5555-5555-555555555555")) + ]; + + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_From_TvpGuid", 5) + .AddTvpParameter("tvp", guids) + .Build(); + + return await dbContext.QueryAsImmutableArrayAsync(sp, cancellationToken); + } + + // ─── Types.tvp_IntGuid — filter by a composite (int, Guid) key ───────── + + public async Task> GetUsersByTvpIntGuid(CancellationToken cancellationToken = default) + { + IEnumerable pairs = + [ + new(1, Guid.Parse("11111111-1111-1111-1111-111111111111")), + new(2, Guid.Parse("22222222-2222-2222-2222-222222222222")), + new(3, Guid.Parse("33333333-3333-3333-3333-333333333333")) + ]; + + var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_From_TvpIntGuid", 5) + .AddTvpParameter("tvp", pairs) + .Build(); + + return await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? []; + } +} \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs index 22b3c6d..1e95f02 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs @@ -1,113 +1,14 @@ namespace CaeriusNet.Exemples.Libs.Commons.Repositories; -public sealed class UsersRepository(ICaeriusNetDbContext dbContext) : IUsersRepository -{ - public async Task> GetAllUsers() - { - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) - .Build(); - - var dbResults = await dbContext.QueryAsIEnumerableAsync(sp); - return dbResults ?? []; - } - - public async Task> GetAllUsersWithFrozenCache() - { - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) - .AddFrozenCache("all_users_frozen") - .Build(); - - var dbResults = await dbContext.QueryAsIEnumerableAsync(sp); - return dbResults ?? []; - } - - public async Task> GetAllUsersWithMemoryCache() - { - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) - .AddInMemoryCache("all_users_memory", TimeSpan.FromMinutes(1)) - .Build(); - - var dbResults = await dbContext.QueryAsIEnumerableAsync(sp); - return dbResults ?? []; - } - - public async Task> GetAllUsersWithRedisCache() - { - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 25) - .AddRedisCache("all_users_redis", TimeSpan.FromMinutes(2)) - .Build(); - - var dbResults = await dbContext.QueryAsIEnumerableAsync(sp); - return dbResults ?? []; - } - - public async Task> GetUsersByTvpIntGuid() - { - IEnumerable usersList = - [ - new(1, Guid.Parse("9ad8423e-f36b-1410-8b92-00ad90cc3640")), - new(2, Guid.Parse("9cd8423e-f36b-1410-8b92-00ad90cc3640")), - new(3, Guid.Parse("9ed8423e-f36b-1410-8b92-00ad90cc3640")), - new(4, Guid.Parse("a0d8423e-f36b-1410-8b92-00ad90cc3640")), - new(5, Guid.Parse("a2d8423e-f36b-1410-8b92-00ad90cc3640")) - ]; - - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_From_TvpIntGuid", 5) - .AddTvpParameter("tvp", usersList) - .Build(); - - var dbResults = await dbContext.QueryAsIEnumerableAsync(sp); - return dbResults ?? []; - } - - public async Task> GetUsersByTvpInt() - { - IEnumerable usersList = - [ - new(6), - new(7), - new(8), - new(9), - new(10) - ]; - - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_From_TvpInt", 5) - .AddTvpParameter("tvp", usersList) - .Build(); - - var dbResults = await dbContext.QueryAsReadOnlyCollectionAsync(sp); - return dbResults; - } - - public async Task> GetUsersByTvpGuid() - { - var guid1 = Guid.Parse("b1d8423e-f36b-1410-8b92-00ad90cc3640"); - var guid2 = Guid.Parse("b6d8423e-f36b-1410-8b92-00ad90cc3640"); - var guid3 = Guid.Parse("bbd8423e-f36b-1410-8b92-00ad90cc3640"); - var guid4 = Guid.Parse("c0d8423e-f36b-1410-8b92-00ad90cc3640"); - var guid5 = Guid.Parse("c5d8423e-f36b-1410-8b92-00ad90cc3640"); - IEnumerable usersList = - [ - new(guid1), - new(guid2), - new(guid3), - new(guid4), - new(guid5) - ]; - - var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_Users_From_TvpGuid", 5) - .AddTvpParameter("tvp", usersList) - .Build(); - - var dbResults = await dbContext.QueryAsImmutableArrayAsync(sp); - return dbResults; - } - - public async Task CreateNewUser() - { - var sp = new StoredProcedureParametersBuilder("Users", "usp_Create_User") - .Build(); - - await dbContext.ExecuteAsync(sp); - } -} \ No newline at end of file +/// +/// Data-access implementation for user-related stored procedures. +/// Split into partial files by concern: +/// +/// — base declaration (this file) +/// UsersRepository.Reads.cs — single-result-set reads + cache tiers +/// UsersRepository.Tvp.cs — Table-Valued Parameter reads +/// UsersRepository.MultiResultSets.cs — multi-result-set reads +/// UsersRepository.Transactions.cs — transactional write scenarios +/// +/// +public sealed partial class UsersRepository(ICaeriusNetDbContext dbContext) : IUsersRepository; \ No newline at end of file diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs new file mode 100644 index 0000000..884f3d7 --- /dev/null +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs @@ -0,0 +1,79 @@ +namespace CaeriusNet.Exemples.Libs.Commons.Services; + +/// +/// Default implementation of . +/// Delegates every operation to , keeping business-logic +/// concerns separated from data-access concerns. +/// +public sealed class UsersService(IUsersRepository repository) : IUsersService +{ + // ─── Reads ─────────────────────────────────────────────────────────────── + + public Task> GetAllUsersAsync(CancellationToken cancellationToken = default) + { + return repository.GetAllUsers(cancellationToken); + } + + public Task> GetAllUsersWithFrozenCacheAsync(CancellationToken cancellationToken = default) + { + return repository.GetAllUsersWithFrozenCache(cancellationToken); + } + + public Task> GetAllUsersWithMemoryCacheAsync(CancellationToken cancellationToken = default) + { + return repository.GetAllUsersWithMemoryCache(cancellationToken); + } + + public Task> GetAllUsersWithRedisCacheAsync(CancellationToken cancellationToken = default) + { + return repository.GetAllUsersWithRedisCache(cancellationToken); + } + + // ─── TVP-driven reads ──────────────────────────────────────────────────── + + public Task> GetUsersByTvpIntAsync(CancellationToken cancellationToken = default) + { + return repository.GetUsersByTvpInt(cancellationToken); + } + + public Task> GetUsersByTvpGuidAsync(CancellationToken cancellationToken = default) + { + return repository.GetUsersByTvpGuid(cancellationToken); + } + + public Task> GetUsersByTvpIntGuidAsync(CancellationToken cancellationToken = default) + { + return repository.GetUsersByTvpIntGuid(cancellationToken); + } + + // ─── Multi result-set reads ────────────────────────────────────────────── + + public Task GetDashboardAsync(CancellationToken cancellationToken = default) + { + return repository.GetDashboardAsync(cancellationToken); + } + + public Task<(IReadOnlyCollection Users, IReadOnlyCollection Orders)> + GetUsersWithOrdersAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken = default) + { + return repository.GetUsersWithOrdersByTvpAsync(userIds, cancellationToken); + } + + // ─── Commands ──────────────────────────────────────────────────────────── + + public Task CreateUserWithFirstOrderAsync(string userName, string orderLabel, decimal amount, + CancellationToken cancellationToken = default) + { + return repository.CreateUserWithFirstOrderAsync(userName, orderLabel, amount, cancellationToken); + } + + public Task DemonstrateClientSideRollbackAsync(string userName, CancellationToken cancellationToken = default) + { + return repository.DemonstrateClientSideRollbackAsync(userName, cancellationToken); + } + + public Task DemonstrateServerSideRollbackAsync(string userName, CancellationToken cancellationToken = default) + { + return repository.DemonstrateServerSideRollbackAsync(userName, cancellationToken); + } +} \ No newline at end of file diff --git a/Exemples/README.md b/Exemples/README.md index ad727c1..7b07096 100644 --- a/Exemples/README.md +++ b/Exemples/README.md @@ -55,12 +55,42 @@ resource bindings. `CaeriusNet.Exemples.Libs.Commons` contains the shared application code used by both console examples. -| Folder | Purpose | -|-----------------|---------------------------------------------------------| -| `Abstractions/` | Repository contracts | -| `Models/` | DTOs with `[GenerateDto]` and TVPs with `[GenerateTvp]` | -| `Repositories/` | Repository implementations built on CaeriusNet | -| `Extensions/` | Dependency injection registration helpers | +| Folder | Purpose | +|-----------------|-------------------------------------------------------------------------------------------------| +| `Abstractions/` | Repository contracts (`IUsersRepository`, `DashboardSnapshot`) | +| `Bootstrap/` | `ExampleDatabaseBootstrapper` — applies `Sql/init.sql` to any SQL Server connection | +| `Models/` | DTOs with `[GenerateDto]` (`UserDto`, `OrderDto`, `UserStatsDto`) and TVPs with `[GenerateTvp]` | +| `Repositories/` | Repository implementations built on CaeriusNet | +| `Sql/` | `init.sql` — schema + tables + TVP types + 9 stored procedures (embedded resource) | +| `Extensions/` | Dependency injection registration helpers | + +### Database bootstrap + +The shared `Sql/init.sql` script creates everything the examples need (schemas `Users` and `Types`, tables `Users.Users` +and `Users.Orders`, TVP types `Types.tvp_*` and 9 stored procedures). It is **idempotent** — every object is dropped +before being re-created. + +- The **Default Console** runs `ExampleDatabaseBootstrapper.EnsureCreatedAsync(connectionString)` on startup. +- The **Aspire AppHost** hands the script to Aspire via `WithCreationScript(...)`, which runs it once on container + provisioning. + +## Demo scenarios + +Both `Program.cs` files run the same scenario sequence so you can compare the two hosting models side-by-side. Each +scenario maps to specific telemetry signals emitted under the `CaeriusNet` `ActivitySource` / `Meter`: + +| # | Scenario | Repository method | Stored procedure | Notable telemetry | +|---|------------------------------------------------------|--------------------------------------|------------------------------------------|--------------------------------------------------------------------------| +| 1 | Single result-set + caches (frozen / memory / Redis) | `GetAllUsers*` | `Users.usp_Get_All_Users` | `caerius.cache.lookups{hit,tier}`; cache HIT skips the DB span | +| 2 | TVP-driven reads | `GetUsersByTvp*` | `Users.usp_Get_Users_From_Tvp*` | `caerius.tvp.used = true`, `caerius.tvp.type_name = Types.tvp_*` | +| 3 | Multi result-set | `GetDashboardAsync` | `Users.usp_Get_Dashboard` | `caerius.resultset.multi = true`, `caerius.resultset.expected_count = 3` | +| 4 | TVP **+** multi result-set | `GetUsersWithOrdersByTvpAsync` | `Users.usp_Get_Users_With_Orders_By_Tvp` | both TVP and multi-RS tags on a single span | +| 5 | Transaction commit | `CreateUserWithFirstOrderAsync` | `usp_Create_User` + `usp_Create_Order` | `caerius.tx = true` on every command inside the transaction | +| 6 | C#-side rollback | `DemonstrateClientSideRollbackAsync` | `usp_Create_User` | success spans tagged `caerius.tx = true`; nothing persisted | +| 7 | SQL-side rollback (`BEGIN CATCH` + `THROW`) | `DemonstrateServerSideRollbackAsync` | `usp_Create_User_Tx_Safe` | error span (`ActivityStatusCode.Error`) + `caerius.sp.errors` counter | + +For the full attribute reference, +see [Documentations/docs/documentation/aspire.md](../Documentations/docs/documentation/aspire.md#tracing--telemetry). ## Project Structure diff --git a/SourceGenerators/Dto/DtoEmitter.cs b/SourceGenerators/Dto/DtoEmitter.cs index fc4f03e..88f755b 100644 --- a/SourceGenerators/Dto/DtoEmitter.cs +++ b/SourceGenerators/Dto/DtoEmitter.cs @@ -95,4 +95,4 @@ private static string Capitalize(string name) if (string.IsNullOrEmpty(name) || char.IsUpper(name[0])) return name; return char.ToUpperInvariant(name[0]) + name.Substring(1); } -} +} \ No newline at end of file diff --git a/SourceGenerators/Helpers/GeneratedCodeInfo.cs b/SourceGenerators/Helpers/GeneratedCodeInfo.cs index 0ae7c47..ddfe5d3 100644 --- a/SourceGenerators/Helpers/GeneratedCodeInfo.cs +++ b/SourceGenerators/Helpers/GeneratedCodeInfo.cs @@ -4,4 +4,4 @@ internal static class GeneratedCodeInfo { internal const string ToolName = "CaeriusNet.Generator"; internal const string Version = "11.0.0"; -} +} \ No newline at end of file diff --git a/SourceGenerators/Tvp/TvpEmitter.cs b/SourceGenerators/Tvp/TvpEmitter.cs index f2402cf..165d443 100644 --- a/SourceGenerators/Tvp/TvpEmitter.cs +++ b/SourceGenerators/Tvp/TvpEmitter.cs @@ -132,4 +132,4 @@ private static string BuildSetCall(ColumnModel column) ? $"if ({assignmentTarget} is null) record.SetDBNull({ordinal}); else {setExpr};" : $"{setExpr};"; } -} +} \ No newline at end of file diff --git a/Src/Builders/CaeriusNetBuilder.cs b/Src/Builders/CaeriusNetBuilder.cs index df39076..a60c700 100644 --- a/Src/Builders/CaeriusNetBuilder.cs +++ b/Src/Builders/CaeriusNetBuilder.cs @@ -12,6 +12,7 @@ public sealed class CaeriusNetBuilder private MemoryCacheOptions? _inMemoryCacheOptions; private string? _redisConnectionString; private string? _sqlServerConnectionString; + private CaeriusTelemetryOptions? _telemetryOptions; private CaeriusNetBuilder(IServiceCollection services) { @@ -49,6 +50,18 @@ public CaeriusNetBuilder WithRedis(string? connectionString) return this; } + /// + /// Configures OpenTelemetry emission behaviour for this CaeriusNet instance. + /// Must be called before to take effect. + /// + /// Telemetry options to apply. + public CaeriusNetBuilder WithTelemetryOptions(CaeriusTelemetryOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _telemetryOptions = options; + return this; + } + /// /// Configures the underlying used by the InMemory cache tier. /// Call this BEFORE the first cached read; later calls replace the underlying cache instance and @@ -95,6 +108,9 @@ public IServiceCollection Build() throw new InvalidOperationException( "SQL Server connection must be configured using WithSqlServer() or WithAspireSqlServer()."); + if (_telemetryOptions is not null) + CaeriusDiagnostics.TelemetryOptions = _telemetryOptions; + if (_inMemoryCacheOptions is not null) InMemoryCacheManager.Configure(_inMemoryCacheOptions); diff --git a/Src/CaeriusNet.csproj b/Src/CaeriusNet.csproj index ed82f21..37ab654 100644 --- a/Src/CaeriusNet.csproj +++ b/Src/CaeriusNet.csproj @@ -86,16 +86,16 @@ - - - + + + - - - - - + + + + + where TResultSet2 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 2, + nameof(QueryMultipleIEnumerableAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (l1, l2); - }, cancellationToken).ConfigureAwait(false); + return (l1, l2); + }, cancellationToken).ConfigureAwait(false); } /// @@ -50,28 +51,29 @@ public static class MultiIEnumerableReadSqlAsyncCommands where TResultSet2 : class, ISpMapper where TResultSet3 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 3, + nameof(QueryMultipleIEnumerableAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, [], []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, l2, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, l2, []); - var l3 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l3 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (l1, l2, l3); - }, cancellationToken).ConfigureAwait(false); + return (l1, l2, l3); + }, cancellationToken).ConfigureAwait(false); } /// @@ -90,34 +92,35 @@ public static class MultiIEnumerableReadSqlAsyncCommands where TResultSet3 : class, ISpMapper where TResultSet4 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 4, + nameof(QueryMultipleIEnumerableAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, [], [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, [], [], []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, l2, [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, l2, [], []); - var l3 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l3 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, l2, l3, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, l2, l3, []); - var l4 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l4 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (l1, l2, l3, l4); - }, cancellationToken).ConfigureAwait(false); + return (l1, l2, l3, l4); + }, cancellationToken).ConfigureAwait(false); } /// @@ -137,40 +140,41 @@ public static class MultiIEnumerableReadSqlAsyncCommands where TResultSet4 : class, ISpMapper where TResultSet5 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 5, + nameof(QueryMultipleIEnumerableAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, [], [], [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, [], [], [], []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, l2, [], [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, l2, [], [], []); - var l3 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l3 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, l2, l3, [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, l2, l3, [], []); - var l4 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l4 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (l1, l2, l3, l4, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (l1, l2, l3, l4, []); - var l5 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var l5 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (l1, l2, l3, l4, l5); - }, cancellationToken).ConfigureAwait(false); + return (l1, l2, l3, l4, l5); + }, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs b/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs index a96db01..b1d0738 100644 --- a/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs @@ -20,22 +20,23 @@ public static class MultiImmutableArrayReadSqlAsyncCommands where TResultSet1 : class, ISpMapper where TResultSet2 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 2, + nameof(QueryMultipleImmutableArrayAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, ImmutableArray.Empty); - var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (a1, a2); - }, cancellationToken).ConfigureAwait(false); + return (a1, a2); + }, cancellationToken).ConfigureAwait(false); } /// @@ -52,28 +53,29 @@ public static class MultiImmutableArrayReadSqlAsyncCommands where TResultSet2 : class, ISpMapper where TResultSet3 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 3, + nameof(QueryMultipleImmutableArrayAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, ImmutableArray.Empty, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, ImmutableArray.Empty, ImmutableArray.Empty); - var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, a2, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, a2, ImmutableArray.Empty); - var a3 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a3 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (a1, a2, a3); - }, cancellationToken).ConfigureAwait(false); + return (a1, a2, a3); + }, cancellationToken).ConfigureAwait(false); } /// @@ -92,35 +94,36 @@ public static class MultiImmutableArrayReadSqlAsyncCommands where TResultSet3 : class, ISpMapper where TResultSet4 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 4, + nameof(QueryMultipleImmutableArrayAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty); - var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, a2, ImmutableArray.Empty, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, a2, ImmutableArray.Empty, ImmutableArray.Empty); - var a3 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a3 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, a2, a3, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, a2, a3, ImmutableArray.Empty); - var a4 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a4 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (a1, a2, a3, a4); - }, cancellationToken).ConfigureAwait(false); + return (a1, a2, a3, a4); + }, cancellationToken).ConfigureAwait(false); } /// @@ -140,42 +143,43 @@ public static class MultiImmutableArrayReadSqlAsyncCommands where TResultSet4 : class, ISpMapper where TResultSet5 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 5, + nameof(QueryMultipleImmutableArrayAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a1 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty); - var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a2 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, a2, ImmutableArray.Empty, ImmutableArray.Empty, - ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, a2, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty); - var a3 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a3 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, a2, a3, ImmutableArray.Empty, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, a2, a3, ImmutableArray.Empty, ImmutableArray.Empty); - var a4 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a4 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (a1, a2, a3, a4, ImmutableArray.Empty); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (a1, a2, a3, a4, ImmutableArray.Empty); - var a5 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var a5 = await MultiResultSetHelper.ReadResultSetAsImmutableArrayAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - return (a1, a2, a3, a4, a5); - }, cancellationToken).ConfigureAwait(false); + return (a1, a2, a3, a4, a5); + }, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs b/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs index a2fa81f..b088ed6 100644 --- a/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs @@ -19,24 +19,25 @@ public static class MultiReadOnlyCollectionReadSqlAsyncCommands where TResultSet1 : class, ISpMapper where TResultSet2 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 2, + nameof(QueryMultipleReadOnlyCollectionAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r1 = l1.AsReadOnly(); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r1 = l1.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r2 = l2.AsReadOnly(); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r2 = l2.AsReadOnly(); - return (r1, r2); - }, cancellationToken).ConfigureAwait(false); + return (r1, r2); + }, cancellationToken).ConfigureAwait(false); } /// @@ -54,31 +55,32 @@ public static class MultiReadOnlyCollectionReadSqlAsyncCommands where TResultSet2 : class, ISpMapper where TResultSet3 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 3, + nameof(QueryMultipleReadOnlyCollectionAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r1 = l1.AsReadOnly(); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r1 = l1.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, [], []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r2 = l2.AsReadOnly(); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r2 = l2.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, r2, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, r2, []); - var l3 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r3 = l3.AsReadOnly(); + var l3 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r3 = l3.AsReadOnly(); - return (r1, r2, r3); - }, cancellationToken).ConfigureAwait(false); + return (r1, r2, r3); + }, cancellationToken).ConfigureAwait(false); } /// @@ -97,38 +99,39 @@ public static class MultiReadOnlyCollectionReadSqlAsyncCommands where TResultSet3 : class, ISpMapper where TResultSet4 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 4, + nameof(QueryMultipleReadOnlyCollectionAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r1 = l1.AsReadOnly(); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r1 = l1.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, [], [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, [], [], []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r2 = l2.AsReadOnly(); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r2 = l2.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, r2, [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, r2, [], []); - var l3 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r3 = l3.AsReadOnly(); + var l3 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r3 = l3.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, r2, r3, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, r2, r3, []); - var l4 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r4 = l4.AsReadOnly(); + var l4 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r4 = l4.AsReadOnly(); - return (r1, r2, r3, r4); - }, cancellationToken).ConfigureAwait(false); + return (r1, r2, r3, r4); + }, cancellationToken).ConfigureAwait(false); } /// @@ -148,45 +151,46 @@ public static class MultiReadOnlyCollectionReadSqlAsyncCommands where TResultSet4 : class, ISpMapper where TResultSet5 : class, ISpMapper { - return await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, async command => - { - await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); + return await CaeriusActivityExtensions.InstrumentMultiResultSetAsync(context, spParameters, 5, + nameof(QueryMultipleReadOnlyCollectionAsync), async command => + { + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess, cancellationToken).ConfigureAwait(false); - var l1 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r1 = l1.AsReadOnly(); + var l1 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r1 = l1.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, [], [], [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, [], [], [], []); - var l2 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r2 = l2.AsReadOnly(); + var l2 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r2 = l2.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, r2, [], [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, r2, [], [], []); - var l3 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r3 = l3.AsReadOnly(); + var l3 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r3 = l3.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, r2, r3, [], []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, r2, r3, [], []); - var l4 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r4 = l4.AsReadOnly(); + var l4 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r4 = l4.AsReadOnly(); - if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) - return (r1, r2, r3, r4, []); + if (!await MultiResultSetHelper.TryMoveNextAsync(reader, cancellationToken).ConfigureAwait(false)) + return (r1, r2, r3, r4, []); - var l5 = await MultiResultSetHelper.ReadResultSetAsync( - reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); - var r5 = l5.AsReadOnly(); + var l5 = await MultiResultSetHelper.ReadResultSetAsync( + reader, spParameters.Capacity, cancellationToken).ConfigureAwait(false); + var r5 = l5.AsReadOnly(); - return (r1, r2, r3, r4, r5); - }, cancellationToken).ConfigureAwait(false); + return (r1, r2, r3, r4, r5); + }, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs b/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs index 322786f..dc5c394 100644 --- a/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs @@ -24,18 +24,29 @@ public static class SimpleReadSqlAsyncCommands CancellationToken cancellationToken = default) where TResultSet : class, ISpMapper { + const string Operation = nameof(FirstQueryAsync); var logger = LoggerProvider.GetLogger(); + // Check cache before starting the SP span so that cache hits do not emit a + // misleading DB span or record SP duration/execution metrics. if (spParameters.CacheType.HasValue && !string.IsNullOrEmpty(spParameters.CacheKey)) if (CacheHelper.TryRetrieveFromCache(spParameters, context.RedisCacheManager, out TResultSet? cachedResult)) { + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType.Value, true); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogCacheHitSkippingExecution(spParameters.CacheKey); return cachedResult; } + else + { + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType.Value, false); + } + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( spParameters.SchemaName, @@ -51,17 +62,22 @@ public static class SimpleReadSqlAsyncCommands if (result is not null) CacheHelper.StoreInCache(spParameters, context.RedisCacheManager, result); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + var rows = result is null ? 0 : 1; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, rows); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, - result is null ? 0 : 1); + (long)elapsedMs, + rows); return result; } catch (SqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); throw new CaeriusNetSqlException( $"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); } @@ -81,20 +97,32 @@ public async ValueTask> QueryAsReadOnlyCollection CancellationToken cancellationToken = default) where TResultSet : class, ISpMapper { + const string Operation = nameof(QueryAsReadOnlyCollectionAsync); var logger = LoggerProvider.GetLogger(); + // Check cache before starting the SP span so that cache hits do not emit a + // misleading DB span or record SP duration/execution metrics. if (CacheHelper.TryRetrieveFromCache(spParameters, context.RedisCacheManager, out ReadOnlyCollection? cachedResult) && cachedResult != null) { - if (spParameters.CacheKey is null) return cachedResult; - if (logger is not null && logger.IsEnabled(LogLevel.Debug)) - logger.LogCacheHitSkippingExecution(spParameters.CacheKey); + if (spParameters.CacheKey is not null) + { + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType!.Value, true); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) + logger.LogCacheHitSkippingExecution(spParameters.CacheKey); + } return cachedResult; } + if (spParameters.CacheType.HasValue && !string.IsNullOrEmpty(spParameters.CacheKey)) + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType.Value, false); + + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( spParameters.SchemaName, @@ -112,17 +140,21 @@ public async ValueTask> QueryAsReadOnlyCollection CacheHelper.StoreInCache(spParameters, context.RedisCacheManager, results); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, results.Count); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + (long)elapsedMs, results.Count); return results; } catch (SqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); throw new CaeriusNetSqlException( $"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); } @@ -142,20 +174,32 @@ public async ValueTask> QueryAsIEnumerableAsync { + const string Operation = nameof(QueryAsIEnumerableAsync); var logger = LoggerProvider.GetLogger(); + // Check cache before starting the SP span so that cache hits do not emit a + // misleading DB span or record SP duration/execution metrics. if (CacheHelper.TryRetrieveFromCache(spParameters, context.RedisCacheManager, out IEnumerable? cachedResult) && cachedResult != null) { - if (spParameters.CacheKey is null) return cachedResult; - if (logger is not null && logger.IsEnabled(LogLevel.Debug)) - logger.LogCacheHitSkippingExecution(spParameters.CacheKey); + if (spParameters.CacheKey is not null) + { + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType!.Value, true); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) + logger.LogCacheHitSkippingExecution(spParameters.CacheKey); + } return cachedResult; } + if (spParameters.CacheType.HasValue && !string.IsNullOrEmpty(spParameters.CacheKey)) + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType.Value, false); + + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( spParameters.SchemaName, @@ -173,17 +217,21 @@ public async ValueTask> QueryAsIEnumerableAsync> QueryAsImmutableArrayAsync { + const string Operation = nameof(QueryAsImmutableArrayAsync); var logger = LoggerProvider.GetLogger(); + // Check cache before starting the SP span so that cache hits do not emit a + // misleading DB span or record SP duration/execution metrics. if (CacheHelper.TryRetrieveFromCache(spParameters, context.RedisCacheManager, out ImmutableArray? cachedResult) && cachedResult.HasValue) { - if (spParameters.CacheKey is null) return cachedResult.Value; - if (logger is not null && logger.IsEnabled(LogLevel.Debug)) - logger.LogCacheHitSkippingExecution(spParameters.CacheKey); + if (spParameters.CacheKey is not null) + { + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType!.Value, true); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) + logger.LogCacheHitSkippingExecution(spParameters.CacheKey); + } return cachedResult.Value; } + if (spParameters.CacheType.HasValue && !string.IsNullOrEmpty(spParameters.CacheKey)) + CaeriusActivityExtensions.RecordCacheLookup(spParameters, spParameters.CacheType.Value, false); + + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( spParameters.SchemaName, @@ -231,17 +291,21 @@ public async ValueTask> QueryAsImmutableArrayAsync { + const string Operation = nameof(FirstQueryAsync); var tx = AsInternal(transaction); var logger = LoggerProvider.GetLogger(); tx.AcquireCommandSlot(); + using var activity = + CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation, true); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation, true); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( @@ -43,17 +47,22 @@ private static ICaeriusNetTransactionInternal AsInternal(ICaeriusNetTransaction var result = await SqlCommandHelperTx.ScalarQueryTxAsync( spParameters, tx.Connection, tx.Transaction, cancellationToken).ConfigureAwait(false); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + var rows = result is null ? 0 : 1; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, rows); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, - result is null ? 0 : 1); + (long)elapsedMs, + rows); return result; } catch (SqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); tx.Poison(); throw new CaeriusNetSqlException( $"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); @@ -71,9 +80,13 @@ public async ValueTask> QueryAsReadOnlyCollection CancellationToken cancellationToken = default) where TResultSet : class, ISpMapper { + const string Operation = nameof(QueryAsReadOnlyCollectionAsync); var tx = AsInternal(transaction); var logger = LoggerProvider.GetLogger(); tx.AcquireCommandSlot(); + using var activity = + CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation, true); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation, true); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( @@ -90,17 +103,21 @@ public async ValueTask> QueryAsReadOnlyCollection ? EmptyCollections.ReadOnlyCollection() : results; + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, results.Count); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + (long)elapsedMs, results.Count); return results; } catch (SqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); tx.Poison(); throw new CaeriusNetSqlException( $"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); @@ -129,9 +146,13 @@ public async ValueTask> QueryAsImmutableArrayAsync { + const string Operation = nameof(QueryAsImmutableArrayAsync); var tx = AsInternal(transaction); var logger = LoggerProvider.GetLogger(); tx.AcquireCommandSlot(); + using var activity = + CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation, true); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation, true); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( @@ -144,17 +165,21 @@ public async ValueTask> QueryAsImmutableArrayAsync( spParameters, tx.Connection, tx.Transaction, cancellationToken).ConfigureAwait(false); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, results.Length); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + (long)elapsedMs, results.Length); return results; } catch (SqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); tx.Poison(); throw new CaeriusNetSqlException( $"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); diff --git a/Src/Commands/Transactions/TransactionWriteSqlAsyncCommands.cs b/Src/Commands/Transactions/TransactionWriteSqlAsyncCommands.cs index 33ddf17..3f4c2da 100644 --- a/Src/Commands/Transactions/TransactionWriteSqlAsyncCommands.cs +++ b/Src/Commands/Transactions/TransactionWriteSqlAsyncCommands.cs @@ -25,9 +25,13 @@ private static ICaeriusNetTransactionInternal AsInternal(ICaeriusNetTransaction StoredProcedureParameters spParameters, CancellationToken cancellationToken = default) { + const string Operation = nameof(ExecuteScalarAsync); var tx = AsInternal(transaction); var logger = LoggerProvider.GetLogger(); tx.AcquireCommandSlot(); + using var activity = + CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation, true); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation, true); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( @@ -41,20 +45,24 @@ private static ICaeriusNetTransactionInternal AsInternal(ICaeriusNetTransaction spParameters, tx.Connection, tx.Transaction, async command => { - var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - return result is DBNull ? default : (T?)result; + var inner = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return inner is DBNull ? default : (T?)inner; }, cancellationToken).ConfigureAwait(false); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureScalarCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds); + (long)elapsedMs); return result; } - catch (CaeriusNetSqlException) + catch (CaeriusNetSqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); tx.Poison(); throw; } @@ -70,9 +78,13 @@ public async ValueTask ExecuteNonQueryAsync( StoredProcedureParameters spParameters, CancellationToken cancellationToken = default) { + const string Operation = nameof(ExecuteNonQueryAsync); var tx = AsInternal(transaction); var logger = LoggerProvider.GetLogger(); tx.AcquireCommandSlot(); + using var activity = + CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation, true); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation, true); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( @@ -87,17 +99,21 @@ public async ValueTask ExecuteNonQueryAsync( command => new ValueTask(command.ExecuteNonQueryAsync(cancellationToken)), cancellationToken).ConfigureAwait(false); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, rowsAffected: rowsAffected); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureNonQueryCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + (long)elapsedMs, rowsAffected); return rowsAffected; } - catch (CaeriusNetSqlException) + catch (CaeriusNetSqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); tx.Poison(); throw; } @@ -113,9 +129,13 @@ public async ValueTask ExecuteAsync( StoredProcedureParameters spParameters, CancellationToken cancellationToken = default) { + const string Operation = nameof(ExecuteAsync); var tx = AsInternal(transaction); var logger = LoggerProvider.GetLogger(); tx.AcquireCommandSlot(); + using var activity = + CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation, true); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation, true); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogExecutingProcedure( @@ -130,15 +150,19 @@ public async ValueTask ExecuteAsync( command => new ValueTask(command.ExecuteNonQueryAsync(cancellationToken)), cancellationToken).ConfigureAwait(false); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, rowsAffected: rowsAffected); + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) logger.LogProcedureNonQueryCompleted( spParameters.SchemaName, spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + (long)elapsedMs, rowsAffected); } - catch (CaeriusNetSqlException) + catch (CaeriusNetSqlException ex) { + CaeriusActivityExtensions.RecordError(activity, tags, ex); tx.Poison(); throw; } diff --git a/Src/Commands/Writes/WriteSqlAsyncCommands.cs b/Src/Commands/Writes/WriteSqlAsyncCommands.cs index ac6df97..3f2cb99 100644 --- a/Src/Commands/Writes/WriteSqlAsyncCommands.cs +++ b/Src/Commands/Writes/WriteSqlAsyncCommands.cs @@ -22,7 +22,10 @@ public static class WriteSqlAsyncCommands StoredProcedureParameters spParameters, CancellationToken cancellationToken = default) { + const string Operation = nameof(ExecuteScalarAsync); var logger = LoggerProvider.GetLogger(); + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) @@ -31,19 +34,30 @@ public static class WriteSqlAsyncCommands spParameters.ProcedureName, spParameters.GetParametersSpan().Length); - var result = await SqlCommandHelper.ExecuteCommandAsync(dbContext, spParameters, async command => + try { - var scalar = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - return scalar is DBNull ? default : (T?)scalar; - }, cancellationToken).ConfigureAwait(false); + var result = await SqlCommandHelper.ExecuteCommandAsync(dbContext, spParameters, async command => + { + var scalar = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return scalar is DBNull ? default : (T?)scalar; + }, cancellationToken).ConfigureAwait(false); - if (logger is not null && logger.IsEnabled(LogLevel.Debug)) - logger.LogProcedureScalarCompleted( - spParameters.SchemaName, - spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs); - return result; + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) + logger.LogProcedureScalarCompleted( + spParameters.SchemaName, + spParameters.ProcedureName, + (long)elapsedMs); + + return result; + } + catch (CaeriusNetSqlException ex) + { + CaeriusActivityExtensions.RecordError(activity, tags, ex); + throw; + } } /// @@ -57,7 +71,10 @@ public async ValueTask ExecuteNonQueryAsync( StoredProcedureParameters spParameters, CancellationToken cancellationToken = default) { + const string Operation = nameof(ExecuteNonQueryAsync); var logger = LoggerProvider.GetLogger(); + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) @@ -66,18 +83,29 @@ public async ValueTask ExecuteNonQueryAsync( spParameters.ProcedureName, spParameters.GetParametersSpan().Length); - var rowsAffected = await SqlCommandHelper.ExecuteCommandAsync(dbContext, spParameters, - command => new ValueTask(command.ExecuteNonQueryAsync(cancellationToken)), - cancellationToken).ConfigureAwait(false); + try + { + var rowsAffected = await SqlCommandHelper.ExecuteCommandAsync(dbContext, spParameters, + command => new ValueTask(command.ExecuteNonQueryAsync(cancellationToken)), + cancellationToken).ConfigureAwait(false); - if (logger is not null && logger.IsEnabled(LogLevel.Debug)) - logger.LogProcedureNonQueryCompleted( - spParameters.SchemaName, - spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, - rowsAffected); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, rowsAffected: rowsAffected); - return rowsAffected; + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) + logger.LogProcedureNonQueryCompleted( + spParameters.SchemaName, + spParameters.ProcedureName, + (long)elapsedMs, + rowsAffected); + + return rowsAffected; + } + catch (CaeriusNetSqlException ex) + { + CaeriusActivityExtensions.RecordError(activity, tags, ex); + throw; + } } /// @@ -91,7 +119,10 @@ public async ValueTask ExecuteAsync( StoredProcedureParameters spParameters, CancellationToken cancellationToken = default) { + const string Operation = nameof(ExecuteAsync); var logger = LoggerProvider.GetLogger(); + using var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(spParameters, Operation); + var tags = CaeriusActivityExtensions.BuildMetricTags(spParameters, Operation); var startTimestamp = Stopwatch.GetTimestamp(); if (logger is not null && logger.IsEnabled(LogLevel.Debug)) @@ -100,16 +131,27 @@ public async ValueTask ExecuteAsync( spParameters.ProcedureName, spParameters.GetParametersSpan().Length); - var rowsAffected = await SqlCommandHelper.ExecuteCommandAsync(dbContext, spParameters, - command => new ValueTask(command.ExecuteNonQueryAsync(cancellationToken)), - cancellationToken).ConfigureAwait(false); + try + { + var rowsAffected = await SqlCommandHelper.ExecuteCommandAsync(dbContext, spParameters, + command => new ValueTask(command.ExecuteNonQueryAsync(cancellationToken)), + cancellationToken).ConfigureAwait(false); - if (logger is not null && logger.IsEnabled(LogLevel.Debug)) - logger.LogProcedureNonQueryCompleted( - spParameters.SchemaName, - spParameters.ProcedureName, - (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, - rowsAffected); + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + CaeriusActivityExtensions.RecordSuccess(activity, tags, elapsedMs, rowsAffected: rowsAffected); + + if (logger is not null && logger.IsEnabled(LogLevel.Debug)) + logger.LogProcedureNonQueryCompleted( + spParameters.SchemaName, + spParameters.ProcedureName, + (long)elapsedMs, + rowsAffected); + } + catch (CaeriusNetSqlException ex) + { + CaeriusActivityExtensions.RecordError(activity, tags, ex); + throw; + } } } } \ No newline at end of file diff --git a/Src/Factories/CaeriusNetTransaction.cs b/Src/Factories/CaeriusNetTransaction.cs index cafca4b..75db98b 100644 --- a/Src/Factories/CaeriusNetTransaction.cs +++ b/Src/Factories/CaeriusNetTransaction.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace CaeriusNet.Factories; /// @@ -15,15 +17,17 @@ internal sealed class CaeriusNetTransaction : ICaeriusNetTransactionInternal private readonly bool _isLoggingEnabled; private readonly ILogger? _logger; private int _commandInFlight; // 0 = none, 1 = busy - private SqlConnection? _connection; private int _state; // 0 = active, 1 = committed, 2 = rolledback, 3 = poisoned, 4 = disposed private SqlTransaction? _transaction; - private CaeriusNetTransaction(SqlConnection connection, SqlTransaction transaction) + private Activity? _txActivity; + + private CaeriusNetTransaction(SqlConnection connection, SqlTransaction transaction, Activity? txActivity) { _connection = connection; _transaction = transaction; + _txActivity = txActivity; _logger = LoggerProvider.GetLogger(); _isLoggingEnabled = _logger != null; } @@ -46,11 +50,15 @@ public async ValueTask CommitAsync(CancellationToken cancellationToken = default try { await Transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + CaeriusActivityExtensions.RecordTransactionOutcome(_txActivity, "committed"); + _txActivity = null; if (_isLoggingEnabled) _logger!.LogTransactionCommitted(); } catch (SqlException ex) { Volatile.Write(ref _state, StatePoisoned); + CaeriusActivityExtensions.RecordTransactionOutcome(_txActivity, "commit-failed", true); + _txActivity = null; throw new CaeriusNetSqlException("Failed to commit SQL Server transaction.", ex); } } @@ -66,11 +74,15 @@ public async ValueTask RollbackAsync(CancellationToken cancellationToken = defau { await Transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); Volatile.Write(ref _state, StateRolledBack); + CaeriusActivityExtensions.RecordTransactionOutcome(_txActivity, "rolled-back"); + _txActivity = null; if (_isLoggingEnabled) _logger!.LogTransactionRolledBack(); } catch (SqlException ex) { Volatile.Write(ref _state, StatePoisoned); + CaeriusActivityExtensions.RecordTransactionOutcome(_txActivity, "rollback-failed", true); + _txActivity = null; throw new CaeriusNetSqlException("Failed to rollback SQL Server transaction.", ex); } } @@ -109,11 +121,16 @@ public async ValueTask DisposeAsync() try { await _transaction.RollbackAsync().ConfigureAwait(false); + var outcome = prev == StatePoisoned ? "poisoned-auto-rollback" : "auto-rollback"; + CaeriusActivityExtensions.RecordTransactionOutcome(_txActivity, outcome, prev == StatePoisoned); + _txActivity = null; if (_isLoggingEnabled) _logger!.LogTransactionRolledBack(); } catch { // Best-effort rollback during disposal; surface no exception to callers. + _txActivity?.Stop(); + _txActivity = null; } if (_transaction is not null) @@ -153,7 +170,12 @@ internal static async ValueTask BeginAsync( .BeginTransactionAsync(isolationLevel, cancellationToken) .ConfigureAwait(false); - var tx = new CaeriusNetTransaction(connection, transaction); + // Start the parent TX activity AFTER the connection is open and the transaction is + // begun. All SP activities started inside this scope will become children of this + // activity because Activity.Current is set to it on the calling async context. + var txActivity = CaeriusActivityExtensions.StartTransactionActivity(isolationLevel); + + var tx = new CaeriusNetTransaction(connection, transaction, txActivity); if (tx._isLoggingEnabled) tx._logger!.LogTransactionStarted(isolationLevel); return tx; } diff --git a/Src/GlobalUsings.cs b/Src/GlobalUsings.cs index 6838704..c8135e6 100644 --- a/Src/GlobalUsings.cs +++ b/Src/GlobalUsings.cs @@ -13,6 +13,7 @@ global using System.Collections.Frozen; global using System.Text.Json; global using CaeriusNet.Caches; +global using CaeriusNet.Telemetry; global using CaeriusNet.Helpers; global using CaeriusNet.Logging; global using Microsoft.Extensions.Caching.Distributed; diff --git a/Src/README.md b/Src/README.md index d6b07fb..d7b98c4 100644 --- a/Src/README.md +++ b/Src/README.md @@ -267,6 +267,42 @@ CaeriusNet uses `[LoggerMessage]` source-generated structured logging with zero- All events include structured properties (cache key, schema, procedure name, elapsed time, row count) for integration with OpenTelemetry, Seq, Application Insights, or any `ILogger` sink. +### Tracing & Metrics (OpenTelemetry / Aspire) + +CaeriusNet also publishes OpenTelemetry-compatible **traces** and **metrics** through the BCL primitives — no +OpenTelemetry SDK package is added to the library. Consumers (typically an Aspire `ServiceDefaults` project) opt-in +by registering the source/meter named `CaeriusNet`: + +```csharp +using CaeriusNet.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource(CaeriusDiagnostics.SourceName)) + .WithMetrics(m => m.AddMeter(CaeriusDiagnostics.SourceName)); +``` + +Spans are `ActivityKind.Client`, named `SP {schema}.{procedure}`, and tagged with the OpenTelemetry DB semantic +conventions (`db.system = mssql`, `db.operation`, `db.statement`) plus library-specific attributes: + +- `caerius.sp.schema`, `caerius.sp.name`, `caerius.sp.command`, `caerius.sp.parameters` (names only by default; + `CaptureParameterValues` defaults to `false` — set it to `true` on `CaeriusTelemetryOptions` to also capture + `@name=value` pairs, but keep it disabled in production to avoid leaking PII or secrets into telemetry back-ends) +- `caerius.tvp.used` (`true`/`false`) and `caerius.tvp.type_name` when a Table-Valued Parameter is attached +- `caerius.resultset.multi` and `caerius.resultset.expected_count` (1 by default, 2/3/4/5 for the multi-RS overloads) +- `caerius.cache.tier` / `caerius.cache.hit` (set on the active span when a cache lookup occurs) +- `caerius.tx = true` when the call runs inside an `ICaeriusNetTransaction` +- `caerius.rows_returned` / `caerius.rows_affected` on success + +Metrics exposed by the `CaeriusNet` meter: + +- `caerius.sp.duration` (Histogram, ms) +- `caerius.sp.executions` (Counter) +- `caerius.sp.errors` (Counter) +- `caerius.cache.lookups` (Counter, tagged with `caerius.cache.tier` and `caerius.cache.hit`) + +When a cache hit short-circuits the SQL call, no DB span is created — only `caerius.cache.lookups{hit=true}` is +emitted, so the Aspire dashboard accurately reflects that the database was not contacted. + ## Documentation Full documentation, samples, and API reference: **[https://caerius.net](https://caerius.net)** diff --git a/Src/Telemetry/CaeriusActivityExtensions.cs b/Src/Telemetry/CaeriusActivityExtensions.cs new file mode 100644 index 0000000..9db0c5f --- /dev/null +++ b/Src/Telemetry/CaeriusActivityExtensions.cs @@ -0,0 +1,321 @@ +using System.Diagnostics; +using System.Text; + +namespace CaeriusNet.Telemetry; + +/// +/// Internal helpers that start instances and emit metrics for the +/// CaeriusNet command pipeline. Designed to be zero-cost when no listener is registered. +/// +internal static class CaeriusActivityExtensions +{ + /// + /// Starts a new span describing a stored-procedure execution. + /// + /// Parameters of the stored procedure being executed. + /// + /// Logical operation name of the calling command (e.g. "FirstQueryAsync", + /// "QueryMultipleImmutableArrayAsync", "ExecuteNonQueryAsync"). + /// + /// + /// when the call runs inside an open ICaeriusNetTransaction. + /// + /// + /// Number of result sets expected by the caller. Defaults to 1. Multi-result-set + /// command overloads pass the tuple arity (2, 3, 4, 5). + /// + /// + /// The started , or when no listener is subscribed + /// to (no allocation in that case). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Activity? StartStoredProcedureActivity( + StoredProcedureParameters spParameters, + string operation, + bool transactional = false, + int expectedResultSetCount = 1) + { + var source = CaeriusDiagnostics.ActivitySource; + if (!source.HasListeners()) + return null; + + var activity = source.StartActivity( + string.Concat("SP ", spParameters.SchemaName, ".", spParameters.ProcedureName), + ActivityKind.Client); + + if (activity is null) + return null; + + activity.SetTag(CaeriusDiagnostics.AttributeNames.DbSystem, + CaeriusDiagnostics.AttributeValues.DbSystemMsSql); + activity.SetTag(CaeriusDiagnostics.AttributeNames.DbOperation, operation); + activity.SetTag(CaeriusDiagnostics.AttributeNames.DbStatement, + string.Concat(spParameters.SchemaName, ".", spParameters.ProcedureName)); + + activity.SetTag(CaeriusDiagnostics.AttributeNames.SpSchema, spParameters.SchemaName); + activity.SetTag(CaeriusDiagnostics.AttributeNames.SpName, spParameters.ProcedureName); + activity.SetTag(CaeriusDiagnostics.AttributeNames.SpCommand, operation); + + var paramsSpan = spParameters.GetParametersSpan(); + if (paramsSpan.Length > 0) + activity.SetTag(CaeriusDiagnostics.AttributeNames.SpParameters, + CaeriusDiagnostics.TelemetryOptions.CaptureParameterValues + ? JoinParameterNamesAndValues(paramsSpan) + : JoinParameterNames(paramsSpan)); + + // Detect TVP usage by scanning the parameter array — no need to store it separately. + var tvpNames = ExtractTvpTypeNames(paramsSpan); + var tvpUsed = tvpNames.Count > 0; + activity.SetTag(CaeriusDiagnostics.AttributeNames.TvpUsed, tvpUsed); + if (tvpUsed) + activity.SetTag(CaeriusDiagnostics.AttributeNames.TvpTypeName, + tvpNames.Count == 1 + ? tvpNames[0] + : string.Join(",", tvpNames)); + + var multi = expectedResultSetCount > 1; + activity.SetTag(CaeriusDiagnostics.AttributeNames.ResultSetMulti, multi); + activity.SetTag(CaeriusDiagnostics.AttributeNames.ResultSetExpectedCount, expectedResultSetCount); + + if (transactional) + activity.SetTag(CaeriusDiagnostics.AttributeNames.Transactional, true); + + return activity; + } + + /// + /// Builds the shared between metrics and spans, identifying the + /// stored procedure, the calling operation, the TVP/multi-result-set context and whether the + /// call is transactional. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static TagList BuildMetricTags( + StoredProcedureParameters spParameters, + string operation, + bool transactional = false, + int expectedResultSetCount = 1) + { + var tvpUsed = ExtractTvpTypeNames(spParameters.GetParametersSpan()).Count > 0; + return new TagList + { + { CaeriusDiagnostics.AttributeNames.SpSchema, spParameters.SchemaName }, + { CaeriusDiagnostics.AttributeNames.SpName, spParameters.ProcedureName }, + { CaeriusDiagnostics.AttributeNames.SpCommand, operation }, + { CaeriusDiagnostics.AttributeNames.TvpUsed, tvpUsed }, + { CaeriusDiagnostics.AttributeNames.ResultSetMulti, expectedResultSetCount > 1 }, + { CaeriusDiagnostics.AttributeNames.Transactional, transactional } + }; + } + + /// + /// Records a SQL error on the activity (as an OpenTelemetry exception event with status = + /// ) and increments the caerius.sp.errors counter. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void RecordError(Activity? activity, in TagList tags, Exception ex) + { + if (activity is not null) + { + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.AddException(ex); + } + + CaeriusDiagnostics.SpErrors.Add(1, tags); + } + + /// + /// Records a successful execution: row count tag on the activity, duration histogram and + /// execution counter. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void RecordSuccess( + Activity? activity, + in TagList tags, + double elapsedMs, + int? rowsReturned = null, + int? rowsAffected = null) + { + if (activity is not null) + { + if (rowsReturned.HasValue) + activity.SetTag(CaeriusDiagnostics.AttributeNames.RowsReturned, rowsReturned.Value); + if (rowsAffected.HasValue) + activity.SetTag(CaeriusDiagnostics.AttributeNames.RowsAffected, rowsAffected.Value); + activity.SetStatus(ActivityStatusCode.Ok); + } + + CaeriusDiagnostics.SpDuration.Record(elapsedMs, tags); + CaeriusDiagnostics.SpExecutions.Add(1, tags); + } + + /// + /// Records a cache lookup outcome on the active span (if any) and on the + /// caerius.cache.lookups counter. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void RecordCacheLookup(StoredProcedureParameters spParameters, CacheType tier, bool hit) + { + var current = Activity.Current; + if (current is not null && current.Source == CaeriusDiagnostics.ActivitySource) + { + current.SetTag(CaeriusDiagnostics.AttributeNames.CacheTier, tier.ToString()); + current.SetTag(CaeriusDiagnostics.AttributeNames.CacheHit, hit); + } + + var tags = new TagList + { + { CaeriusDiagnostics.AttributeNames.SpSchema, spParameters.SchemaName }, + { CaeriusDiagnostics.AttributeNames.SpName, spParameters.ProcedureName }, + { CaeriusDiagnostics.AttributeNames.CacheTier, tier.ToString() }, + { CaeriusDiagnostics.AttributeNames.CacheHit, hit } + }; + CaeriusDiagnostics.CacheLookups.Add(1, tags); + } + + private static string JoinParameterNames(ReadOnlySpan parameters) + { + if (parameters.Length == 1) + return parameters[0].ParameterName; + + var capacity = 0; + for (var i = 0; i < parameters.Length; i++) + capacity += parameters[i].ParameterName.Length + 1; + + return string.Create(capacity - 1, parameters.ToArray(), static (span, src) => + { + var pos = 0; + for (var i = 0; i < src.Length; i++) + { + if (i > 0) + span[pos++] = ','; + var name = src[i].ParameterName.AsSpan(); + name.CopyTo(span[pos..]); + pos += name.Length; + } + }); + } + + /// + /// Builds @name=value pairs for every parameter, separated by commas. + /// TVP parameters show [TVP] instead of the row data. Used only when + /// is enabled. + /// + private static string JoinParameterNamesAndValues(ReadOnlySpan parameters) + { + var sb = new StringBuilder(); + for (var i = 0; i < parameters.Length; i++) + { + if (i > 0) + sb.Append(','); + var p = parameters[i]; + sb.Append(p.ParameterName); + sb.Append('='); + sb.Append(FormatParameterValue(p)); + } + + return sb.ToString(); + } + + private static string FormatParameterValue(SqlParameter parameter) + { + if (parameter.SqlDbType == SqlDbType.Structured) + return "[TVP]"; + if (parameter.Value is null or DBNull) + return "(null)"; + return parameter.Value.ToString() ?? "(null)"; + } + + /// + /// Scans and collects the + /// of every Table-Valued Parameter (those with ). + /// Returns an empty list when no TVP is present. This avoids any additional state on + /// . + /// + private static List ExtractTvpTypeNames(ReadOnlySpan parameters) + { + List? result = null; + for (var i = 0; i < parameters.Length; i++) + { + ref readonly var p = ref parameters[i]; + if (p.SqlDbType == SqlDbType.Structured && !string.IsNullOrEmpty(p.TypeName)) + (result ??= new List(2)).Add(p.TypeName); + } + + return result ?? []; + } + + /// + /// Wraps a multi-result-set stored-procedure execution with a CaeriusNet activity span and metrics. + /// is passed directly so that telemetry can report the + /// requested arity without modifying . + /// + /// Result tuple type produced by the executor. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static async ValueTask InstrumentMultiResultSetAsync( + ICaeriusNetDbContext context, + StoredProcedureParameters spParameters, + int expectedResultSetCount, + string operation, + Func> execute, + CancellationToken cancellationToken) + { + using var activity = StartStoredProcedureActivity(spParameters, operation, + expectedResultSetCount: expectedResultSetCount); + var tags = BuildMetricTags(spParameters, operation, expectedResultSetCount: expectedResultSetCount); + var startTimestamp = Stopwatch.GetTimestamp(); + + try + { + var result = await SqlCommandHelper.ExecuteCommandAsync(context, spParameters, execute, cancellationToken) + .ConfigureAwait(false); + + var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + RecordSuccess(activity, tags, elapsedMs); + return result; + } + catch (CaeriusNetSqlException ex) + { + RecordError(activity, tags, ex); + throw; + } + } + + /// + /// Starts a parent representing the lifetime of an + /// scope. All stored-procedure activities + /// started while this activity is will be + /// automatically nested under it, so the Aspire dashboard shows a single + /// cohesive trace instead of one orphaned span per command. + /// + /// SQL Server isolation level of the transaction. + /// + /// The started , or when no listener is + /// subscribed (zero-cost path). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Activity? StartTransactionActivity(IsolationLevel isolationLevel) + { + var source = CaeriusDiagnostics.ActivitySource; + if (!source.HasListeners()) + return null; + + var activity = source.StartActivity("TX"); + activity?.SetTag(CaeriusDiagnostics.AttributeNames.TxIsolationLevel, isolationLevel.ToString()); + return activity; + } + + /// + /// Marks the transaction activity with its final outcome and stops it. + /// A is a no-op. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void RecordTransactionOutcome(Activity? activity, string outcome, bool isError = false) + { + if (activity is null) + return; + + activity.SetTag(CaeriusDiagnostics.AttributeNames.TxOutcome, outcome); + activity.SetStatus(isError ? ActivityStatusCode.Error : ActivityStatusCode.Ok, outcome); + activity.Stop(); + } +} \ No newline at end of file diff --git a/Src/Telemetry/CaeriusDiagnostics.cs b/Src/Telemetry/CaeriusDiagnostics.cs new file mode 100644 index 0000000..0112f38 --- /dev/null +++ b/Src/Telemetry/CaeriusDiagnostics.cs @@ -0,0 +1,129 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace CaeriusNet.Telemetry; + +/// +/// Centralized and +/// for the CaeriusNet library. +/// +/// +/// +/// The library only emits OpenTelemetry-compatible signals via the BCL +/// ( / ). +/// It does not depend on any OpenTelemetry SDK package — consumers (typically an +/// Aspire ServiceDefaults project) opt-in by calling: +/// +/// +/// tracing.AddSource(CaeriusDiagnostics.SourceName); +/// metrics.AddMeter(CaeriusDiagnostics.SourceName); +/// +/// +/// Spans are created with and follow the OpenTelemetry +/// Semantic Conventions for database calls (db.system, db.name, +/// db.operation, db.statement) plus library-specific attributes prefixed by +/// caerius.*. +/// +/// +public static class CaeriusDiagnostics +{ + /// + /// Public name of the and + /// emitted by CaeriusNet. + /// + public const string SourceName = "CaeriusNet"; + + /// + /// Version of the library reported on the and . + /// + public static readonly string SourceVersion = + typeof(CaeriusDiagnostics).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + /// + /// used by all CaeriusNet stored-procedure + /// and transaction commands. + /// + public static readonly ActivitySource ActivitySource = new(SourceName, SourceVersion); + + /// + /// used by CaeriusNet to publish duration, + /// execution count, error count and cache lookup metrics. + /// + public static readonly Meter Meter = new(SourceName, SourceVersion); + + /// + /// Histogram of stored-procedure execution duration, in milliseconds. + /// + public static readonly Histogram SpDuration = Meter.CreateHistogram( + "caerius.sp.duration", + "ms", + "Duration of CaeriusNet stored procedure executions."); + + /// + /// Counter of stored-procedure executions that completed successfully. + /// Failed calls are counted in instead of here. + /// Cache-hit short-circuits are not counted — no SQL was executed. + /// + public static readonly Counter SpExecutions = Meter.CreateCounter( + "caerius.sp.executions", + description: "Number of CaeriusNet stored procedure executions that completed successfully."); + + /// + /// Counter of stored-procedure executions that ended in a SQL error. + /// + public static readonly Counter SpErrors = Meter.CreateCounter( + "caerius.sp.errors", + description: "Number of CaeriusNet stored procedure executions that failed with a SQL error."); + + /// + /// Counter of cache lookups performed by CaeriusNet. Tagged with caerius.cache.tier + /// (InMemory, Frozen, Redis) and caerius.cache.hit (true/false). + /// + public static readonly Counter CacheLookups = Meter.CreateCounter( + "caerius.cache.lookups", + description: "Number of CaeriusNet cache lookups, tagged by tier and hit/miss."); + + /// + /// Current telemetry options applied to every span and metric emitted by CaeriusNet. + /// Set once at startup via CaeriusNetBuilder.WithTelemetryOptions(…); + /// defaults to with all options at their safe defaults. + /// + public static CaeriusTelemetryOptions TelemetryOptions { get; internal set; } = new(); + + /// + /// Well-known attribute names emitted on spans and metric tags. + /// + public static class AttributeNames + { + // OpenTelemetry semantic conventions for databases. + public const string DbSystem = "db.system"; + public const string DbName = "db.name"; + public const string DbOperation = "db.operation"; + public const string DbStatement = "db.statement"; + + // Library-specific tags. + public const string SpSchema = "caerius.sp.schema"; + public const string SpName = "caerius.sp.name"; + public const string SpParameters = "caerius.sp.parameters"; + public const string SpCommand = "caerius.sp.command"; + public const string TvpUsed = "caerius.tvp.used"; + public const string TvpTypeName = "caerius.tvp.type_name"; + public const string ResultSetMulti = "caerius.resultset.multi"; + public const string ResultSetExpectedCount = "caerius.resultset.expected_count"; + public const string RowsReturned = "caerius.rows_returned"; + public const string RowsAffected = "caerius.rows_affected"; + public const string CacheTier = "caerius.cache.tier"; + public const string CacheHit = "caerius.cache.hit"; + public const string Transactional = "caerius.tx"; + public const string TxIsolationLevel = "caerius.tx.isolation_level"; + public const string TxOutcome = "caerius.tx.outcome"; + } + + /// + /// Well-known constant tag values. + /// + public static class AttributeValues + { + public const string DbSystemMsSql = "mssql"; + } +} \ No newline at end of file diff --git a/Src/Telemetry/CaeriusTelemetryOptions.cs b/Src/Telemetry/CaeriusTelemetryOptions.cs new file mode 100644 index 0000000..f2cb7a1 --- /dev/null +++ b/Src/Telemetry/CaeriusTelemetryOptions.cs @@ -0,0 +1,26 @@ +namespace CaeriusNet.Telemetry; + +/// +/// Controls what CaeriusNet emits to OpenTelemetry spans and metrics. +/// Configure via CaeriusNetBuilder.WithTelemetryOptions(...). +/// +/// +/// +/// By default, only parameter names are captured in the +/// caerius.sp.parameters span tag (e.g. @userId,@name). +/// Enabling adds the runtime values +/// (e.g. @userId=42,@name=Alice) which is useful during development +/// but should be disabled in production to avoid leaking PII or secrets into +/// telemetry back-ends. +/// +/// +public sealed class CaeriusTelemetryOptions +{ + /// + /// When , the caerius.sp.parameters tag includes + /// both parameter names and their runtime values (e.g. @id=42,@Name=Alice). + /// Defaults to to prevent accidental PII exposure in + /// production telemetry pipelines. + /// + public bool CaptureParameterValues { get; init; } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs b/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs index 0872ec4..742d5a1 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs +++ b/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs @@ -82,4 +82,4 @@ private static IReadOnlyList BuildMetadataReferences() return references; } -} +} \ No newline at end of file diff --git a/Tests/CaeriusNet.IntegrationTests/CaeriusNet.IntegrationTests.csproj b/Tests/CaeriusNet.IntegrationTests/CaeriusNet.IntegrationTests.csproj index 7106be6..8b68117 100644 --- a/Tests/CaeriusNet.IntegrationTests/CaeriusNet.IntegrationTests.csproj +++ b/Tests/CaeriusNet.IntegrationTests/CaeriusNet.IntegrationTests.csproj @@ -20,7 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs new file mode 100644 index 0000000..66180ea --- /dev/null +++ b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs @@ -0,0 +1,62 @@ +namespace CaeriusNet.Tests.Builders; + +/// +/// Verifies that produces a +/// with and the correct +/// — the information that the telemetry layer reads by +/// scanning . +/// +public sealed class StoredProcedureParametersBuilderTelemetryTests +{ + [Fact] + public void Build_NoTvp_HasNoStructuredParameters() + { + var sp = new StoredProcedureParametersBuilder("dbo", "sp_Test") + .AddParameter("@id", 1, SqlDbType.Int) + .Build(); + + var structured = sp.GetParametersSpan() + .ToArray() + .Where(p => p.SqlDbType == SqlDbType.Structured) + .ToList(); + + Assert.Empty(structured); + } + + [Fact] + public void AddTvpParameter_Single_EmitsStructuredParameterWithTypeName() + { + var items = new List { new(1) }; + + var sp = new StoredProcedureParametersBuilder("dbo", "sp_Test") + .AddTvpParameter("@Ids", items) + .Build(); + + var structured = sp.GetParametersSpan() + .ToArray() + .Where(p => p.SqlDbType == SqlDbType.Structured) + .ToList(); + + Assert.Single(structured); + Assert.Equal(TestTvpItem.TvpTypeName, structured[0].TypeName); + } + + [Fact] + public void AddTvpParameter_Multiple_EmitsAllStructuredParameters() + { + var items = new List { new(1), new(2) }; + + var sp = new StoredProcedureParametersBuilder("dbo", "sp_Test") + .AddTvpParameter("@A", items) + .AddTvpParameter("@B", items) + .Build(); + + var structured = sp.GetParametersSpan() + .ToArray() + .Where(p => p.SqlDbType == SqlDbType.Structured) + .ToList(); + + Assert.Equal(2, structured.Count); + Assert.All(structured, p => Assert.Equal(TestTvpItem.TvpTypeName, p.TypeName)); + } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Tests/CaeriusNet.Tests.csproj b/Tests/CaeriusNet.Tests/CaeriusNet.Tests.csproj index 3ae53e2..e99dbfc 100644 --- a/Tests/CaeriusNet.Tests/CaeriusNet.Tests.csproj +++ b/Tests/CaeriusNet.Tests/CaeriusNet.Tests.csproj @@ -28,7 +28,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/Tests/CaeriusNet.Tests/GlobalUsings.cs b/Tests/CaeriusNet.Tests/GlobalUsings.cs index 220498f..75b1eb8 100644 --- a/Tests/CaeriusNet.Tests/GlobalUsings.cs +++ b/Tests/CaeriusNet.Tests/GlobalUsings.cs @@ -11,6 +11,7 @@ global using CaeriusNet.Builders; global using CaeriusNet.Caches; global using CaeriusNet.Commands.Transactions; +global using CaeriusNet.Telemetry; global using CaeriusNet.Exceptions; global using CaeriusNet.Factories; global using CaeriusNet.Mappers; diff --git a/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs b/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs new file mode 100644 index 0000000..da22cfe --- /dev/null +++ b/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs @@ -0,0 +1,246 @@ +using System.Diagnostics; + +namespace CaeriusNet.Tests.Telemetry; + +/// +/// Verifies the helpers create OpenTelemetry-compatible +/// spans with the expected db.* + caerius.* tags, and react correctly to error, +/// success and cache-lookup signals. These tests do not require a SQL Server. +/// +[Collection(TelemetryTestsCollection.Name)] +public sealed class CaeriusActivityExtensionsTests +{ + /// Builds a with optional raw parameters. + private static StoredProcedureParameters BuildSp(SqlParameter[]? parameters = null) + { + return new StoredProcedureParameters( + "Users", + "usp_GetUsers", + 16, + parameters ?? [], + null, + null, + null); + } + + /// Builds a structured that acts as a TVP. + private static SqlParameter Tvp(string paramName, string typeName) + { + return new SqlParameter(paramName, SqlDbType.Structured) { TypeName = typeName }; + } + + private static (List activities, ActivityListener listener) StartListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == CaeriusDiagnostics.SourceName, + Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => captured.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + return (captured, listener); + } + + [Fact] + public void StartStoredProcedureActivity_NoListener_ReturnsNull() + { + var sp = BuildSp(); + + var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync"); + + Assert.Null(activity); + } + + [Fact] + public void StartStoredProcedureActivity_SetsSchemaProcedureAndDbTags() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp([new SqlParameter("@id", SqlDbType.Int)]); + + using (var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync")) + { + Assert.NotNull(activity); + Assert.Equal(ActivityKind.Client, activity.Kind); + Assert.Equal("SP Users.usp_GetUsers", activity.OperationName); + } + + var stopped = Assert.Single(captured); + var tags = stopped.TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Equal("mssql", tags[CaeriusDiagnostics.AttributeNames.DbSystem]); + Assert.Equal("FirstQueryAsync", tags[CaeriusDiagnostics.AttributeNames.DbOperation]); + Assert.Equal("Users.usp_GetUsers", tags[CaeriusDiagnostics.AttributeNames.DbStatement]); + Assert.Equal("Users", tags[CaeriusDiagnostics.AttributeNames.SpSchema]); + Assert.Equal("usp_GetUsers", tags[CaeriusDiagnostics.AttributeNames.SpName]); + Assert.Equal("FirstQueryAsync", tags[CaeriusDiagnostics.AttributeNames.SpCommand]); + Assert.Equal("@id", tags[CaeriusDiagnostics.AttributeNames.SpParameters]); + Assert.Equal(false, tags[CaeriusDiagnostics.AttributeNames.TvpUsed]); + Assert.Equal(false, tags[CaeriusDiagnostics.AttributeNames.ResultSetMulti]); + Assert.Equal(1, tags[CaeriusDiagnostics.AttributeNames.ResultSetExpectedCount]); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void StartStoredProcedureActivity_TvpUsage_DetectedFromStructuredParameter() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp([Tvp("@tvp", "Users.UsersIntTvp")]); + + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "QueryAsImmutableArrayAsync")) + { + } + + var tags = captured.Single().TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Equal(true, tags[CaeriusDiagnostics.AttributeNames.TvpUsed]); + Assert.Equal("Users.UsersIntTvp", tags[CaeriusDiagnostics.AttributeNames.TvpTypeName]); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void StartStoredProcedureActivity_MultipleTvps_AreCommaSeparated() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp([Tvp("@a", "Users.A"), Tvp("@b", "Users.B")]); + + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "ExecuteAsync")) + { + } + + var tag = captured.Single().TagObjects + .Single(t => t.Key == CaeriusDiagnostics.AttributeNames.TvpTypeName).Value; + Assert.Equal("Users.A,Users.B", tag); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void StartStoredProcedureActivity_MultiResultSet_IsReflectedInTags() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp(); + + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "QueryMultipleImmutableArrayAsync", + expectedResultSetCount: 3)) + { + } + + var tags = captured.Single().TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Equal(true, tags[CaeriusDiagnostics.AttributeNames.ResultSetMulti]); + Assert.Equal(3, tags[CaeriusDiagnostics.AttributeNames.ResultSetExpectedCount]); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void StartStoredProcedureActivity_Transactional_AddsCaeriusTxTag() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp(); + + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "ExecuteAsync", true)) + { + } + + var tags = captured.Single().TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Equal(true, tags[CaeriusDiagnostics.AttributeNames.Transactional]); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void StartStoredProcedureActivity_NoParameters_OmitsParametersTag() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp(); + + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "ExecuteAsync")) + { + } + + Assert.DoesNotContain(captured.Single().TagObjects, + t => t.Key == CaeriusDiagnostics.AttributeNames.SpParameters); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void RecordSuccess_SetsRowsAndOkStatus() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp(); + using (var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync")) + { + var tags = CaeriusActivityExtensions.BuildMetricTags(sp, "FirstQueryAsync"); + CaeriusActivityExtensions.RecordSuccess(activity, tags, 12.5, 42); + } + + var stopped = captured.Single(); + Assert.Equal(ActivityStatusCode.Ok, stopped.Status); + var rows = stopped.TagObjects.Single(t => t.Key == CaeriusDiagnostics.AttributeNames.RowsReturned).Value; + Assert.Equal(42, rows); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void RecordError_SetsErrorStatusAndAttachesException() + { + var (captured, listener) = StartListener(); + try + { + var sp = BuildSp(); + var error = new InvalidOperationException("boom"); + using (var activity = CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync")) + { + var tags = CaeriusActivityExtensions.BuildMetricTags(sp, "FirstQueryAsync"); + CaeriusActivityExtensions.RecordError(activity, tags, error); + } + + var stopped = captured.Single(); + Assert.Equal(ActivityStatusCode.Error, stopped.Status); + Assert.Equal("boom", stopped.StatusDescription); + Assert.Contains(stopped.Events, e => e.Name == "exception"); + } + finally + { + listener.Dispose(); + } + } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs b/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs new file mode 100644 index 0000000..3aaa999 --- /dev/null +++ b/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace CaeriusNet.Tests.Telemetry; + +/// +/// Verifies that CaeriusNet metric instruments are reachable through a +/// and emit the expected tags. +/// +[Collection(TelemetryTestsCollection.Name)] +public sealed class CaeriusDiagnosticsMetricsTests +{ + private static StoredProcedureParameters Sp() + { + return new StoredProcedureParameters( + "Users", "usp_Test", 16, [], null, null, null); + } + + private static MeterListener StartListener(string instrumentName, MeasurementCapture capture) + where T : struct + { + var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == CaeriusDiagnostics.SourceName && instrument.Name == instrumentName) + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + capture.Items.Add((value, tags.ToArray()))); + listener.Start(); + return listener; + } + + [Fact] + public void RecordSuccess_EmitsDurationAndExecutions() + { + var durationCapture = new MeasurementCapture(); + var execCapture = new MeasurementCapture(); + using var dl = StartListener("caerius.sp.duration", durationCapture); + using var el = StartListener("caerius.sp.executions", execCapture); + + var sp = Sp(); + var tags = CaeriusActivityExtensions.BuildMetricTags(sp, "FirstQueryAsync"); + CaeriusActivityExtensions.RecordSuccess(null, tags, 8.0, 5); + + Assert.Single(durationCapture.Items); + Assert.Equal(8.0, durationCapture.Items.Single().value); + Assert.Single(execCapture.Items); + + var execTags = execCapture.Items.Single().tags + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Equal("Users", execTags[CaeriusDiagnostics.AttributeNames.SpSchema]); + Assert.Equal("usp_Test", execTags[CaeriusDiagnostics.AttributeNames.SpName]); + Assert.Equal("FirstQueryAsync", execTags[CaeriusDiagnostics.AttributeNames.SpCommand]); + Assert.Equal(false, execTags[CaeriusDiagnostics.AttributeNames.TvpUsed]); + Assert.Equal(false, execTags[CaeriusDiagnostics.AttributeNames.ResultSetMulti]); + Assert.Equal(false, execTags[CaeriusDiagnostics.AttributeNames.Transactional]); + } + + [Fact] + public void RecordError_EmitsErrorCounter() + { + var capture = new MeasurementCapture(); + using var listener = StartListener("caerius.sp.errors", capture); + + var sp = Sp(); + var tags = CaeriusActivityExtensions.BuildMetricTags(sp, "ExecuteAsync", true); + CaeriusActivityExtensions.RecordError(null, tags, new InvalidOperationException("err")); + + var item = Assert.Single(capture.Items); + Assert.Equal(1L, item.value); + var tagDict = item.tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Equal(true, tagDict[CaeriusDiagnostics.AttributeNames.Transactional]); + } + + [Fact] + public void RecordCacheLookup_HitAndMiss_AreTagged() + { + var capture = new MeasurementCapture(); + using var listener = StartListener("caerius.cache.lookups", capture); + + var sp = Sp(); + CaeriusActivityExtensions.RecordCacheLookup(sp, CacheType.InMemory, true); + CaeriusActivityExtensions.RecordCacheLookup(sp, CacheType.Redis, false); + + Assert.Equal(2, capture.Items.Count); + + var hits = capture.Items + .Select(i => i.tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)) + .ToList(); + + Assert.Contains(hits, t => + (string)t[CaeriusDiagnostics.AttributeNames.CacheTier]! == "InMemory" && + (bool)t[CaeriusDiagnostics.AttributeNames.CacheHit]!); + Assert.Contains(hits, t => + (string)t[CaeriusDiagnostics.AttributeNames.CacheTier]! == "Redis" && + !(bool)t[CaeriusDiagnostics.AttributeNames.CacheHit]!); + } + + [Fact] + public void SourceNameAndVersion_ArePopulated() + { + Assert.Equal("CaeriusNet", CaeriusDiagnostics.SourceName); + Assert.False(string.IsNullOrEmpty(CaeriusDiagnostics.SourceVersion)); + Assert.Equal(CaeriusDiagnostics.SourceName, CaeriusDiagnostics.ActivitySource.Name); + Assert.Equal(CaeriusDiagnostics.SourceName, CaeriusDiagnostics.Meter.Name); + } + + private sealed class MeasurementCapture where T : struct + { + public ConcurrentBag<(T value, KeyValuePair[] tags)> Items { get; } = new(); + } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs b/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs new file mode 100644 index 0000000..1a45670 --- /dev/null +++ b/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs @@ -0,0 +1,144 @@ +using System.Diagnostics; + +namespace CaeriusNet.Tests.Telemetry; + +/// +/// Verifies and its integration with +/// and +/// . +/// +[Collection(TelemetryTestsCollection.Name)] +public sealed class CaeriusTelemetryOptionsTests : IDisposable +{ + private readonly CaeriusTelemetryOptions _previousOptions = CaeriusDiagnostics.TelemetryOptions; + + public void Dispose() + { + // Restore the global options after each test to avoid cross-test pollution. + CaeriusDiagnostics.TelemetryOptions = _previousOptions; + } + + private static StoredProcedureParameters SpWithParam(string name, object value, SqlDbType type) + { + var param = new SqlParameter(name, type) { Value = value }; + return new StoredProcedureParameters("Users", "usp_Test", 1, [param], null, null, null); + } + + private static StoredProcedureParameters SpWithTvp(string name) + { + var param = new SqlParameter(name, SqlDbType.Structured) { TypeName = "Users.TestTvp" }; + return new StoredProcedureParameters("Users", "usp_Test", 1, [param], null, null, null); + } + + private static (List activities, ActivityListener listener) StartListener() + { + var captured = new List(); + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == CaeriusDiagnostics.SourceName, + Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => captured.Add(a) + }; + ActivitySource.AddActivityListener(listener); + return (captured, listener); + } + + [Fact] + public void Default_CaptureParameterValues_IsFalse() + { + var options = new CaeriusTelemetryOptions(); + Assert.False(options.CaptureParameterValues); + } + + [Fact] + public void WhenCaptureParameterValues_False_TagContainsOnlyNames() + { + CaeriusDiagnostics.TelemetryOptions = new CaeriusTelemetryOptions { CaptureParameterValues = false }; + + var (captured, listener) = StartListener(); + try + { + var sp = SpWithParam("@userId", 42, SqlDbType.Int); + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync")) + { + } + + var tag = captured.Single().TagObjects + .Single(t => t.Key == CaeriusDiagnostics.AttributeNames.SpParameters).Value as string; + Assert.Equal("@userId", tag); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void WhenCaptureParameterValues_True_TagContainsNamesAndValues() + { + CaeriusDiagnostics.TelemetryOptions = new CaeriusTelemetryOptions { CaptureParameterValues = true }; + + var (captured, listener) = StartListener(); + try + { + var sp = SpWithParam("@userId", 42, SqlDbType.Int); + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync")) + { + } + + var tag = captured.Single().TagObjects + .Single(t => t.Key == CaeriusDiagnostics.AttributeNames.SpParameters).Value as string; + Assert.Equal("@userId=42", tag); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void WhenCaptureParameterValues_True_TvpShowsPlaceholder() + { + CaeriusDiagnostics.TelemetryOptions = new CaeriusTelemetryOptions { CaptureParameterValues = true }; + + var (captured, listener) = StartListener(); + try + { + var sp = SpWithTvp("@ids"); + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "QueryAsImmutableArrayAsync")) + { + } + + var tag = captured.Single().TagObjects + .Single(t => t.Key == CaeriusDiagnostics.AttributeNames.SpParameters).Value as string; + Assert.Equal("@ids=[TVP]", tag); + } + finally + { + listener.Dispose(); + } + } + + [Fact] + public void WhenCaptureParameterValues_True_NullValueShowsNullPlaceholder() + { + CaeriusDiagnostics.TelemetryOptions = new CaeriusTelemetryOptions { CaptureParameterValues = true }; + + var (captured, listener) = StartListener(); + try + { + var sp = SpWithParam("@name", DBNull.Value, SqlDbType.NVarChar); + using (CaeriusActivityExtensions.StartStoredProcedureActivity(sp, "FirstQueryAsync")) + { + } + + var tag = captured.Single().TagObjects + .Single(t => t.Key == CaeriusDiagnostics.AttributeNames.SpParameters).Value as string; + Assert.Equal("@name=(null)", tag); + } + finally + { + listener.Dispose(); + } + } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs b/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs new file mode 100644 index 0000000..ec771b0 --- /dev/null +++ b/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs @@ -0,0 +1,14 @@ +namespace CaeriusNet.Tests.Telemetry; + +/// +/// xUnit collection marker for all test classes that read or mutate the process-wide static +/// . +/// Tests in this collection are serialised (never run in parallel with each other), +/// preventing races where one test's option change interferes with another test's assertions. +/// +[CollectionDefinition(Name)] +public sealed class TelemetryTestsCollection +{ + /// Collection name referenced by . + public const string Name = "TelemetryTests"; +}