From 3d03418ff081ce47ea1ce4b5decc0d4d91c8883b Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sun, 5 Jul 2026 20:26:59 +0300 Subject: [PATCH 1/6] Introduce ScopedAsyncLocal --- .github/workflows/pr.yml | 2 +- CodeMe.slnx | 1 + Directory.Build.props | 2 + src/CodeMe.Basics/CodeMe.Basics.csproj | 9 + src/CodeMe.Basics/README.md | 3 + .../Threading/ScopedAsyncLocal.cs | 214 ++++++++++++++++++ src/CodeMe.Basics/packages.lock.json | 6 + .../README.md | 28 +++ .../CodeMe.ServiceErrors.csproj | 7 - src/CodeMe.ServiceErrors/README.md | 8 +- 10 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 src/CodeMe.Basics/CodeMe.Basics.csproj create mode 100644 src/CodeMe.Basics/README.md create mode 100644 src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs create mode 100644 src/CodeMe.Basics/packages.lock.json create mode 100644 src/CodeMe.ServiceErrors.Abstractions/README.md diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a7ad19c..46cadf9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: run: dotnet test --report-trx --results-directory=".artifacts/test-results" --no-build --no-restore || true - name: Publish Test Report uses: dorny/test-reporter@v3 - if: always() + if: ${{ !cancelled() }} with: name: .NET Core Tests path: ".artifacts/test-results/**/*.trx" diff --git a/CodeMe.slnx b/CodeMe.slnx index af69706..79be52a 100644 --- a/CodeMe.slnx +++ b/CodeMe.slnx @@ -4,6 +4,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 7869a30..2c59d5d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -41,12 +41,14 @@ true snupkg true + README.md + diff --git a/src/CodeMe.Basics/CodeMe.Basics.csproj b/src/CodeMe.Basics/CodeMe.Basics.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/CodeMe.Basics/CodeMe.Basics.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/CodeMe.Basics/README.md b/src/CodeMe.Basics/README.md new file mode 100644 index 0000000..e59ef87 --- /dev/null +++ b/src/CodeMe.Basics/README.md @@ -0,0 +1,3 @@ +# CodeMe.Basics + +CodeMe.ServiceErrors is a library for simple reusable infrastructure types that are missing in BCL. \ No newline at end of file diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs new file mode 100644 index 0000000..73fdab0 --- /dev/null +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs @@ -0,0 +1,214 @@ +using System.Collections.Immutable; + +namespace CodeMe.Basics.Threading; + +/// +/// Basic building block for ambient contexts. Based on . +/// +/// The type of the ambient data. +public sealed class ScopedAsyncLocal + where T : class +{ + /// + /// Async local scope lifetime. + /// The caller MUST call or there may be a memory leak. + /// + internal sealed class Scope : IDisposable + { + private readonly ScopedAsyncLocal _owner; + + private T? _value; + + public Scope(ScopedAsyncLocal owner) + { + _owner = owner; + } + + public Scope(ScopedAsyncLocal owner, T? value) + : this(owner) + { + _value = value; + IsInitialized = true; + } + + public bool IsInitialized { get; private set; } + + public bool IsDisposed { get; private set; } + + public T? Value + { + get + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + if (!IsInitialized) + { + throw new InvalidOperationException("The scope value is not initialized."); + } + + return _value; + } + } + + public void Initialize(T? value) + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + if (IsInitialized) + { + throw new InvalidOperationException("The scope value is already initialized."); + } + + _value = value; + IsInitialized = true; + } + + public void Dispose() + { + if (!IsDisposed) + { + _owner.AssertIsCurrentScope(this); + _value = null; + IsInitialized = false; + IsDisposed = true; + _owner.PopDisposedScopes(); + } + } + } + + /// + /// Design decisions: + /// 1. We do support asynchronous initialization. + /// 2. On initialization there is no way to update AsyncLocal's value + /// for the calling method after first await + /// as the continuation is being run using a copy of parent execution context. + /// So, we have to store AsyncLocal's value before initialization. + /// 3. We cannot revert store operation for the parent context so there may be cases + /// when we leave parent context AsyncLocal's value in non-initialized state. + /// 4. It seems the only viable option is to store stack of scopes, + /// to perform cleanup in begin / end scope methods + /// and to take first initialized scope in the Current accessor. + /// + private readonly AsyncLocal> _current; + + private readonly bool _validateDisposeOrder; + + /// + /// Creates ambient context + /// + /// Fail for out-of-order scope dispose. + public ScopedAsyncLocal(bool validateDisposeOrder = false) + { + _current = new AsyncLocal>(); + _validateDisposeOrder = validateDisposeOrder; + } + + /// + /// The current ambient value. + /// + public T? Current => CurrentScope?.Value; + + /// + /// The current ambient scope. + /// + private Scope? CurrentScope + { + // Returns first initialized scope value or default. + // Check the comment of the _current field for the justification. + get + { + if (_current.Value is not { } stack) + { + return null; + } + + foreach (var scope in stack) + { + if (scope.IsInitialized) + { + return scope; + } + } + + return null; + } + } + + /// + /// Begins a new scope with new ambient value. + /// The caller MUST call or there may be a memory leak. + /// + /// The new scope ambient value. Will be replaced with previous one on scope disposal. + /// to restore the parent scope. + public IDisposable BeginScope(T? value) + { + var newScope = new Scope(this, value); + + PushScope(newScope); + + return newScope; + } + + /// + /// Begins a new scope with new ambient value. + /// The caller MUST call or there may be a memory leak. + /// + /// + /// Async factory for the new scope ambient value. Value will be replaced with previous one on + /// scope disposal. + /// + /// to restore the parent scope. + public async Task BeginScopeAsync(Func> valueFactory) + { + var newScope = new Scope(this); + + PushScope(newScope); + + try + { + var value = await valueFactory(); + newScope.Initialize(value); + } + catch (Exception ex) + { + newScope.Dispose(); + throw; + } + + return newScope; + } + + private void PushScope(Scope newScope) + { + PopDisposedScopes(); + _current.Value = _current.Value is { } stack + ? stack.Push(newScope) + : [newScope]; + } + + private void AssertIsCurrentScope(Scope expected) + { + if (_validateDisposeOrder && expected is { IsInitialized: true } && !ReferenceEquals(expected, CurrentScope)) + { + throw new InvalidOperationException( + "Scope's Current value mismatch. Please do dispose scopes in reverse order of scope creation."); + } + } + + private void PopDisposedScopes() + { + // Check the comment of the _current field for the justification. + if (_current.Value is not { } stack) + { + return; + } + + var originalStack = stack; + while (!stack.IsEmpty && stack.Peek().IsDisposed) stack = stack.Pop(); + + if (!ReferenceEquals(stack, originalStack)) + { + _current.Value = stack; + } + } +} \ No newline at end of file diff --git a/src/CodeMe.Basics/packages.lock.json b/src/CodeMe.Basics/packages.lock.json new file mode 100644 index 0000000..6afd678 --- /dev/null +++ b/src/CodeMe.Basics/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors.Abstractions/README.md b/src/CodeMe.ServiceErrors.Abstractions/README.md new file mode 100644 index 0000000..812b0a5 --- /dev/null +++ b/src/CodeMe.ServiceErrors.Abstractions/README.md @@ -0,0 +1,28 @@ +# CodeMe.ServiceErrors + +CodeMe.ServiceErrors.Abstractions is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. + +## Minimal example + +Handling the errors: +```csharp +using CodeMe.ServiceErrors; + +// Error URI: problem://orders-api/orders/order-not-found, status 404 +var descriptor = ErrorDescriptor.NotFound( + group: ErrorGroupUri.Create("problem", "orders-api", "orders"), + code: "order-not-found"); + +var error = new ServiceError(descriptor, "Order 66 was not found"); + +// ... + +if (error.Matches(descriptor)) +{ + // handle the error +} +``` + +# Documentation + +Check [documentation](https://github.com/ig-sinicyn/CodeMe) for more details and examples. \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj b/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj index ba53439..3ae79da 100644 --- a/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj +++ b/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj @@ -5,9 +5,6 @@ Library for typed service errors (Problem Details-alike DTOs) errors;typed errors - - - README.md @@ -18,8 +15,4 @@ - - - - \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors/README.md b/src/CodeMe.ServiceErrors/README.md index 3ad8964..593949f 100644 --- a/src/CodeMe.ServiceErrors/README.md +++ b/src/CodeMe.ServiceErrors/README.md @@ -2,7 +2,7 @@ CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. -### Minimal example +## Minimal example Handling the errors: ```csharp @@ -80,4 +80,8 @@ internal sealed class OrderNotFoundException : ServiceException { } } -``` \ No newline at end of file +``` + +# Documentation + +Check [documentation](https://github.com/ig-sinicyn/CodeMe) for more details and examples. \ No newline at end of file From b6a8c9857001cfe07e29cf7e432646096eea914e Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Thu, 9 Jul 2026 22:14:33 +0300 Subject: [PATCH 2/6] Draft for ScopedAsyncLocal --- .editorconfig | 1 + CodeMe.sln.DotSettings | 1 + CodeMe.slnx | 4 + README.md | 241 +------------- docs/Basics/AsyncLocal/CustomScope.md | 131 ++++++++ docs/Basics/AsyncLocal/README.md | 80 +++++ docs/ServiceErrors/README.md | 306 ++++++++++++++++++ docs/docs.csproj | 7 + src/CodeMe.Basics/README.md | 86 ++++- .../Threading/ScopedAsyncLocal.cs | 159 +++++---- .../README.md | 2 +- src/CodeMe.ServiceErrors/README.md | 2 +- .../CodeMe.Basics.UnitTests.csproj | 7 + .../CustomScopeTests.cs | 84 +++++ .../ScopedAsyncLocalTests.cs | 269 +++++++++++++++ .../packages.lock.json | 139 ++++++++ 16 files changed, 1218 insertions(+), 301 deletions(-) create mode 100644 docs/Basics/AsyncLocal/CustomScope.md create mode 100644 docs/Basics/AsyncLocal/README.md create mode 100644 docs/ServiceErrors/README.md create mode 100644 docs/docs.csproj create mode 100644 tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj create mode 100644 tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs create mode 100644 tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs create mode 100644 tests/CodeMe.Basics.UnitTests/packages.lock.json diff --git a/.editorconfig b/.editorconfig index 101a042..615d4c4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,7 @@ csharp_style_prefer_primary_constructors = false resharper_align_multiline_binary_expressions_chain = false resharper_braces_for_foreach = required resharper_braces_for_ifelse = required +resharper_braces_for_while = required resharper_csharp_keep_blank_lines_in_code = 1 resharper_csharp_keep_blank_lines_in_declarations = 1 resharper_csharp_wrap_before_binary_opsign = true diff --git a/CodeMe.sln.DotSettings b/CodeMe.sln.DotSettings index 3c68084..2efc434 100644 --- a/CodeMe.sln.DotSettings +++ b/CodeMe.sln.DotSettings @@ -1,4 +1,5 @@  + HINT HINT SUGGESTION True diff --git a/CodeMe.slnx b/CodeMe.slnx index 79be52a..82d0539 100644 --- a/CodeMe.slnx +++ b/CodeMe.slnx @@ -1,4 +1,7 @@ + + + @@ -9,6 +12,7 @@ + diff --git a/README.md b/README.md index 002235e..b30d998 100644 --- a/README.md +++ b/README.md @@ -6,47 +6,6 @@ CodeMe is a set of small, focused, reusable libraries aimed to reduce amount of CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. -## Introduction - -The core model is built around a few simple concepts: - -* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. -* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. -* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). -* `ServiceErrorDto` represents serializable service error format. -* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. -* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. - -Typical usage scenarios include: - -* Enforcing usage of well-known domain errors. -* Exposing a predictable error contract from HTTP APIs or gRPC services. -* Registering error definitions in DI so services can create and rehydrate errors consistently. -* Passing errors across process boundaries using a serializable error payload. -* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. - -### Minimal example - -Handling the errors: -```csharp -using CodeMe.ServiceErrors; - -// Error URI: problem://orders-api/orders/order-not-found, status 404 -var descriptor = ErrorDescriptor.NotFound( - group: ErrorGroupUri.Create("problem", "orders-api", "orders"), - code: "order-not-found"); - -var error = new ServiceError(descriptor, "Order 66 was not found"); - -// ... - -if (error.Matches(descriptor)) -{ - // handle the error -} -``` - -Well-known errors, conversions and DI registration: ```csharp using CodeMe.ServiceErrors; using CodeMe.ServiceErrors.DependencyInjection; @@ -105,202 +64,6 @@ internal sealed class OrderNotFoundException : ServiceException } ``` -## Advanced usage - -### Checking for well-known errors - -All error-related types do provide `Matches(...)` / `MatchesAny()` methods. Match logic works as follows: -* x matches to StatusCode: exact match. -* x matches to ErrorUri: exact match. -* x matches to ErrorGroupUri: match if x.Group is descendant of specified error group. -* x matches to ErrorDescriptor: match if x.Type and x.StatusCode are equal to descriptor's type and status code. - -Example usage: -```csharp -using CodeMe.ServiceErrors; - -var rootGroup = ErrorGroupUri.Create("problem", "orders-api"); -var otherAppGroup = ErrorGroupUri.Create("problem", "users-api"); -var notFoundErrorUri = ErrorUri.Create(rootGroup.SubGroup("orders"), "order-not-found"); -var notFoundDescriptor = new ErrorDescriptor(notFoundErrorUri, ErrorStatusCode.NotFound); - -var error = new ServiceError(notFoundDescriptor, "Order 404 was not found"); -var exception = new ServiceException(error); - -// Test for error descriptor (checks for type and status code) -if (error.Matches(notFoundDescriptor)) -{ -} - -// Test for error groups (checks if any group do contain descriptor's error group) -if (notFoundDescriptor.MatchesAny(rootGroup, otherAppGroup)) -{ -} - -// Test for problem type URI -try -{ -} -catch (ServiceException ex) when (ex.Matches(notFoundErrorUri)) -{ -} - -// Test for error status code -try -{ -} -catch (Exception ex) - when (ex is IServiceException x && x.Matches(ErrorStatusCode.NotFound)) -{ -} -``` - -### Serialization - -To convert between a `ServiceError` and a serializable `ServiceErrorDto`, use `IServiceErrorFactory`. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); - -ServiceErrorDto dto = factory.CreateDto(serviceError); -// DTO content in JSON format: -// { -// "scheme": "problem", -// "application": "orders-api", -// "category": "orders", -// "code": "order-not-found", -// "statusCode": "NotFound", -// "message": "Order 404 was not found" -// } - -ServiceError restored = factory.CreateError(dto); -``` - -### Exception Mapping - -You can attach an exception type to a well-known error descriptor or error group with the `ServiceExceptionAttribute` family. When a matching error is turned into an exception, the factory instantiates the configured exception type. The target exception type must expose a public constructor that accepts `ServiceError` as a single argument. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -ServiceError serviceError = new ServiceError(OrderNotFound, "Order 42 was not found"); -IServiceException exception = factory.CreateException(serviceError); // returns OrderNotFoundException - -// ... - -ServiceError restoredError = factory.CreateError((Exception)exception); -``` - -#### Unknown exceptions - -If exception is not registered, the `IServiceErrorFactory.CreateError()` method will return ServiceException with error code derived from exception's type. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -var ex = new InvalidOperationException("Something strange happened"); -var error = factory.CreateError(ex); -// StatusCode: Internal -// Type: "problem://orders-api/invalid-operation" -// Message: "Something strange happened" -// InnerException: ex -``` - - -### DI registrations of well-known errors - -The DI extensions support three common registration patterns: - -- Register a specific well-known errors type. -- Register all well-known error types from one assembly. -- Register well-known error types from an assembly and its referenced assemblies. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; -using static WellKnownOrderApiErrors; - -var services = new ServiceCollection(); - -services - .AddServiceErrors(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)); - -// or -services - .AddServiceErrors(RootGroup) - .AddAssembly(typeof(WellKnownOrderApiErrors).Assembly, filterByServiceErrorsAttribute: false); - -services - .AddServiceErrors(RootGroup) - .AddAssemblyAndDependencies( - typeof(WellKnownOrderApiErrors).Assembly, - referenceNamePrefix: "CodeMe.", - filterByServiceErrorsAttribute: true); -``` - -The `Add` overload registers the static error container type directly. `AddAssembly` scans a single assembly for static classes marked with `[ServiceErrors]` or for classes containing public static fields of well-known error types, depending on the `filterByServiceErrorsAttribute` flag. `AddAssemblyAndDependencies` walks the assembly graph and registers error definitions from matching dependencies. If `referenceNamePrefix` is specified, only root assembly and assemblies whose name starts with the prefix will be scanned. - -#### Typed error factories and per-factory well-known error registration - -In some cases it is useful to have a custom error factory configuration instead of the default one. As example, you may want to have a specialized error factory for client of some external service and do not want external service errors to be used across the rest of your application. Meet the typed factory concept. You have to create a marker interface derived from `IServiceErrorFactory` and use it as a type argument for the `AddServiceErrors()` call. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.DependencyInjection; -using CodeMe.ServiceErrors.Serializable; -using Microsoft.Extensions.DependencyInjection; -using static WellKnownOrderApiErrors; - -var services = new ServiceCollection(); -services - .AddServiceErrors(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)); - -public interface IOrdersErrorFactory : IServiceErrorFactory -{ -} -``` - -With this setup, the container can resolve `IOrdersErrorFactory` as a typed service error factory while still using the same well-known error registration model. - -### DI-free error factory - -For scenarios where you do not want to use DI, you can create a service error factory directly using `DefaultServiceErrorFactoryBuilder`. Same configuration, no DI. +# CodeMe.Basics -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); - -ServiceErrorDto dto = factory.CreateDto(serviceError); -``` +CodeMe.Basics contains BCL-style extensions such as `ScopedAsyncLocal` and others. \ No newline at end of file diff --git a/docs/Basics/AsyncLocal/CustomScope.md b/docs/Basics/AsyncLocal/CustomScope.md new file mode 100644 index 0000000..bcbd8ff --- /dev/null +++ b/docs/Basics/AsyncLocal/CustomScope.md @@ -0,0 +1,131 @@ +```csharp +using System.Data; +using System.Data.Common; +using CodeMe.Basics.Threading; + +var unitOfWorkManager = new UnitOfWorkManager(); +var repository = new UserRepository(unitOfWorkManager); // Usually, this is injected via DI container. + +await using (var unitOfWork = await unitOfWorkManager.BeginUnitOfWorkAsync()) +{ + var user = await repository.GetUserAsync(id: 123); + + // ... + + await unitOfWork.CommitAsync(); +} + +// UserRepository.GetUserAsync() is called within the context of an ambient unit of work. +async ValueTask GetUserAsync(long id) +{ + var unitOfWork = unitOfWorkManager.RequiredCurrent; + + return await unitOfWork.Connection.QueryFirstAsync(...); +} + + +// Simplified version for demonstration purposes. In real-world scenarios, consider using a more robust implementation. +public class UnitOfWorkManager +{ + private readonly Func> _connectionFactory; + + private readonly ScopedAsyncLocal _scopedAsyncLocal = + new ScopedAsyncLocal(validateDisposeOrder: true); + + public UnitOfWork RequiredCurrent => _scopedAsyncLocal.Current + ?? throw new InvalidOperationException("No ambient unit of work."); + + public ValueTask BeginUnitOfWorkAsync(IsolationLevel isolation = IsolationLevel.ReadCommitted) + { + // IMPORTANT: + // The BeginScopeInitialization method SHOULD be called in the synchronous part of the method. + // Otherwise, the new AsyncLocal value will not be stored in the caller's execution context, + var scope = _scopedAsyncLocal.BeginScopeInitialization(); + return BeginUnitOfWorkAsync(scope, isolation); + } + + private async ValueTask BeginUnitOfWorkAsync( + ScopedAsyncLocal.Scope scope, + IsolationLevel isolation) + { + // Asynchronous part. + // Performs step-by-step initialization of the UnitOfWork instance + // and assigns it to the scope. If any exception occurs, UnitOfWork (and all related resources) will be disposed. + var result = new UnitOfWork(); + try + { + result.Initialize(scope); + var connection = await _connectionFactory(); + result.Initialize(connection); + await connection.OpenAsync(); + + var transaction = await connection.BeginTransactionAsync(isolation); + result.Initialize(transaction); + + // Assign fully constructed UnitOfWork to the scope. + // This is the only place where the scope value is set. + scope.Initialize(result); + + return result; + } + catch (Exception) + { + await result.DisposeAsync(); + throw; + } + } +} + +public sealed class UnitOfWork : IAsyncDisposable +{ + private DbConnection? _connection; + private DbTransaction? _transaction; + private IDisposable _asyncScope; + private bool _transactionClosed; + + public DbConnection Connection { get; private set; } + + public DbTransaction Transaction { get; private set; } + + public void Initialize(DbConnection connection) => _connection = connection; + + public void Initialize(DbTransaction transaction) => _transaction = transaction; + + public void Initialize(IDisposable asyncScope) => _asyncScope = asyncScope; + + public async ValueTask CommitAsync() + { + await _transaction.CommitAsync(); + _transactionClosed = true; + } + + public async ValueTask RollbackAsync() + { + await _transaction.CommitAsync(); + _transactionClosed = true; + } + + public ValueTask DisposeAsync() + { + // IMPORTANT: + // For performance-sensitive code + // it is recommended to Dispose scope in synchronous part of DisposeAsync. + // The trick slightly reduces the memory usage + // as it clears AsyncLocal value in the caller's execution context. + _asyncScope?.Dispose(); + return DisposeCoreAsync(); + } + + private async ValueTask DisposeCoreAsync() + { + // Asynchronous part. Nothing special here. + if (!_transactionClosed) + { + await _transaction.RollbackAsync(); + } + + await _transaction.DisposeAsync(); + await _connection.DisposeAsync(); + } +} +``` \ No newline at end of file diff --git a/docs/Basics/AsyncLocal/README.md b/docs/Basics/AsyncLocal/README.md new file mode 100644 index 0000000..013efba --- /dev/null +++ b/docs/Basics/AsyncLocal/README.md @@ -0,0 +1,80 @@ + +# ScopedAsyncLocal + +`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. + +## Scenarios and example of usage + +Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. + +```csharp +using CodeMe.Basics.Threading; + +var context = new ScopedAsyncLocal(); + +using (context.BeginScope("request-1")) +{ + Console.WriteLine(context.Current); // request-1 + + using (context.BeginScope("nested")) + { + Console.WriteLine(context.Current); // nested + } + + Console.WriteLine(context.Current); // request-1 +} + +Console.WriteLine(context.Current); // null +``` +n t +`ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. + +```csharp +var local = new ScopedAsyncLocal(); + +using (await local.BeginScopeAsync(() => new ValueTask("request-2"))) +{ + Console.WriteLine(local.Current); // request-2 +} +``` + +The constructor can be used with `validateDisposeOrder: true` to detect out-of-order scope disposal. + +## Using BeginScopeAsync from helper methods + +`BeginScopeAsync` initializes the scope asynchronously through a value factory. If you call it from a helper method, keep the helper synchronous and avoid `await` in the call. Otherwise new scope will not be propagated back to the caller. The value factory itself may use `await`. + +```csharp + private Task BeginUnitOfWorkAsync( + ScopedAsyncLocal local, + CancellationToken cancellation = default) => + local.BeginScopeAsync( + async () => + { + // The body is simplified for demonstration purposes. + DbConnection? connection = null; + DbTransaction? transaction = null; + try + { + connection = await _connectionFactory.CreateConnectionAsync(cancellation); + await connection.OpenAsync(cancellation); + transaction = await connection.BeginTransactionAsync(cancellation); + + return new UnitOfWork(connection, transaction); + } + catch (Exception ex) + { + if (transaction != null) + { + await transaction.DisposeAsync(); + } + + if (connection != null) + { + await connection.DisposeAsync(); + } + + throw; + } + }); +``` \ No newline at end of file diff --git a/docs/ServiceErrors/README.md b/docs/ServiceErrors/README.md new file mode 100644 index 0000000..002235e --- /dev/null +++ b/docs/ServiceErrors/README.md @@ -0,0 +1,306 @@ +# CodeMe + +CodeMe is a set of small, focused, reusable libraries aimed to reduce amount of boring boilerplate code in .NET applications. + +# CodeMe.ServiceErrors + +CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. + +## Introduction + +The core model is built around a few simple concepts: + +* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. +* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. +* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). +* `ServiceErrorDto` represents serializable service error format. +* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. +* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. + +Typical usage scenarios include: + +* Enforcing usage of well-known domain errors. +* Exposing a predictable error contract from HTTP APIs or gRPC services. +* Registering error definitions in DI so services can create and rehydrate errors consistently. +* Passing errors across process boundaries using a serializable error payload. +* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. + +### Minimal example + +Handling the errors: +```csharp +using CodeMe.ServiceErrors; + +// Error URI: problem://orders-api/orders/order-not-found, status 404 +var descriptor = ErrorDescriptor.NotFound( + group: ErrorGroupUri.Create("problem", "orders-api", "orders"), + code: "order-not-found"); + +var error = new ServiceError(descriptor, "Order 66 was not found"); + +// ... + +if (error.Matches(descriptor)) +{ + // handle the error +} +``` + +Well-known errors, conversions and DI registration: +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.DependencyInjection; +using CodeMe.ServiceErrors.Serializable; +using Microsoft.Extensions.DependencyInjection; +using static WellKnownOrderApiErrors; + +var services = new ServiceCollection(); +services + .AddServiceErrors(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)); + +using var provider = services.BuildServiceProvider(); +var errorFactory = provider.GetRequiredService(); + +ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); +ServiceErrorDto dto = errorFactory.CreateDto(error); +ServiceError errorFromDto = errorFactory.CreateError(dto); +// DTO content in JSON format: +// { +// "scheme": "problem", +// "application": "orders-api", +// "category": "orders", +// "code": "order-not-found", +// "statusCode": "NotFound", +// "message": "Order 42 was not found" +// } + +// returns OrderNotFoundException +IServiceException exception = errorFactory.CreateException(errorFromDto); +ServiceError errorFromException = exception.Error; + +[ServiceErrors] +internal static class WellKnownOrderApiErrors +{ + public static readonly ErrorGroupUri RootGroup = ErrorGroupUri.Create("problem", "orders-api"); + + public static readonly ErrorGroupUri OrdersGroup = RootGroup.SubGroup("orders"); + + [ServiceException] + public static readonly ErrorDescriptor OrderNotFound = + ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); +} + +internal sealed class OrderNotFoundException : ServiceException +{ + public OrderNotFoundException(ServiceError error) + : base(AssertMatches(OrderNotFound, error)) + { + } + + public OrderNotFoundException(string message, Exception? innerException = null) + : base(OrderNotFound, message, innerException) + { + } +} +``` + +## Advanced usage + +### Checking for well-known errors + +All error-related types do provide `Matches(...)` / `MatchesAny()` methods. Match logic works as follows: +* x matches to StatusCode: exact match. +* x matches to ErrorUri: exact match. +* x matches to ErrorGroupUri: match if x.Group is descendant of specified error group. +* x matches to ErrorDescriptor: match if x.Type and x.StatusCode are equal to descriptor's type and status code. + +Example usage: +```csharp +using CodeMe.ServiceErrors; + +var rootGroup = ErrorGroupUri.Create("problem", "orders-api"); +var otherAppGroup = ErrorGroupUri.Create("problem", "users-api"); +var notFoundErrorUri = ErrorUri.Create(rootGroup.SubGroup("orders"), "order-not-found"); +var notFoundDescriptor = new ErrorDescriptor(notFoundErrorUri, ErrorStatusCode.NotFound); + +var error = new ServiceError(notFoundDescriptor, "Order 404 was not found"); +var exception = new ServiceException(error); + +// Test for error descriptor (checks for type and status code) +if (error.Matches(notFoundDescriptor)) +{ +} + +// Test for error groups (checks if any group do contain descriptor's error group) +if (notFoundDescriptor.MatchesAny(rootGroup, otherAppGroup)) +{ +} + +// Test for problem type URI +try +{ +} +catch (ServiceException ex) when (ex.Matches(notFoundErrorUri)) +{ +} + +// Test for error status code +try +{ +} +catch (Exception ex) + when (ex is IServiceException x && x.Matches(ErrorStatusCode.NotFound)) +{ +} +``` + +### Serialization + +To convert between a `ServiceError` and a serializable `ServiceErrorDto`, use `IServiceErrorFactory`. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); + +ServiceErrorDto dto = factory.CreateDto(serviceError); +// DTO content in JSON format: +// { +// "scheme": "problem", +// "application": "orders-api", +// "category": "orders", +// "code": "order-not-found", +// "statusCode": "NotFound", +// "message": "Order 404 was not found" +// } + +ServiceError restored = factory.CreateError(dto); +``` + +### Exception Mapping + +You can attach an exception type to a well-known error descriptor or error group with the `ServiceExceptionAttribute` family. When a matching error is turned into an exception, the factory instantiates the configured exception type. The target exception type must expose a public constructor that accepts `ServiceError` as a single argument. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +ServiceError serviceError = new ServiceError(OrderNotFound, "Order 42 was not found"); +IServiceException exception = factory.CreateException(serviceError); // returns OrderNotFoundException + +// ... + +ServiceError restoredError = factory.CreateError((Exception)exception); +``` + +#### Unknown exceptions + +If exception is not registered, the `IServiceErrorFactory.CreateError()` method will return ServiceException with error code derived from exception's type. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +var ex = new InvalidOperationException("Something strange happened"); +var error = factory.CreateError(ex); +// StatusCode: Internal +// Type: "problem://orders-api/invalid-operation" +// Message: "Something strange happened" +// InnerException: ex +``` + + +### DI registrations of well-known errors + +The DI extensions support three common registration patterns: + +- Register a specific well-known errors type. +- Register all well-known error types from one assembly. +- Register well-known error types from an assembly and its referenced assemblies. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using static WellKnownOrderApiErrors; + +var services = new ServiceCollection(); + +services + .AddServiceErrors(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)); + +// or +services + .AddServiceErrors(RootGroup) + .AddAssembly(typeof(WellKnownOrderApiErrors).Assembly, filterByServiceErrorsAttribute: false); + +services + .AddServiceErrors(RootGroup) + .AddAssemblyAndDependencies( + typeof(WellKnownOrderApiErrors).Assembly, + referenceNamePrefix: "CodeMe.", + filterByServiceErrorsAttribute: true); +``` + +The `Add` overload registers the static error container type directly. `AddAssembly` scans a single assembly for static classes marked with `[ServiceErrors]` or for classes containing public static fields of well-known error types, depending on the `filterByServiceErrorsAttribute` flag. `AddAssemblyAndDependencies` walks the assembly graph and registers error definitions from matching dependencies. If `referenceNamePrefix` is specified, only root assembly and assemblies whose name starts with the prefix will be scanned. + +#### Typed error factories and per-factory well-known error registration + +In some cases it is useful to have a custom error factory configuration instead of the default one. As example, you may want to have a specialized error factory for client of some external service and do not want external service errors to be used across the rest of your application. Meet the typed factory concept. You have to create a marker interface derived from `IServiceErrorFactory` and use it as a type argument for the `AddServiceErrors()` call. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.DependencyInjection; +using CodeMe.ServiceErrors.Serializable; +using Microsoft.Extensions.DependencyInjection; +using static WellKnownOrderApiErrors; + +var services = new ServiceCollection(); +services + .AddServiceErrors(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)); + +public interface IOrdersErrorFactory : IServiceErrorFactory +{ +} +``` + +With this setup, the container can resolve `IOrdersErrorFactory` as a typed service error factory while still using the same well-known error registration model. + +### DI-free error factory + +For scenarios where you do not want to use DI, you can create a service error factory directly using `DefaultServiceErrorFactoryBuilder`. Same configuration, no DI. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); + +ServiceErrorDto dto = factory.CreateDto(serviceError); +``` diff --git a/docs/docs.csproj b/docs/docs.csproj new file mode 100644 index 0000000..5fba55e --- /dev/null +++ b/docs/docs.csproj @@ -0,0 +1,7 @@ + + + + netstandard2.0 + + + diff --git a/src/CodeMe.Basics/README.md b/src/CodeMe.Basics/README.md index e59ef87..46178a4 100644 --- a/src/CodeMe.Basics/README.md +++ b/src/CodeMe.Basics/README.md @@ -1,3 +1,87 @@ # CodeMe.Basics -CodeMe.ServiceErrors is a library for simple reusable infrastructure types that are missing in BCL. \ No newline at end of file +CodeMe.Basics is a library for simple reusable infrastructure types that are missing in BCL. + +# ScopedAsyncLocal + +`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. + +## Scenarios and example of usage + +Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. + +```csharp +using CodeMe.Basics.Threading; + +var context = new ScopedAsyncLocal(); + +using (context.BeginScope("request-1")) +{ + Console.WriteLine(context.Current); // request-1 + + using (context.BeginScope("nested")) + { + Console.WriteLine(context.Current); // nested + } + + Console.WriteLine(context.Current); // request-1 +} + +Console.WriteLine(context.Current); // null +``` +n t +`ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. + +```csharp +var local = new ScopedAsyncLocal(); + +using (await local.BeginScopeAsync(() => new ValueTask("request-2"))) +{ + Console.WriteLine(local.Current); // request-2 +} +``` + +The constructor can be used with `validateDisposeOrder: true` to detect out-of-order scope disposal. + +## Using BeginScopeAsync from helper methods + +`BeginScopeAsync` initializes the scope asynchronously through a value factory. If you call it from a helper method, keep the helper synchronous and avoid `await` in the call. Otherwise new scope will not be propagated back to the caller. The value factory itself may use `await`. + +```csharp + private Task BeginUnitOfWorkAsync( + ScopedAsyncLocal local, + CancellationToken cancellation = default) => + local.BeginScopeAsync( + async () => + { + // The body is simplified for demonstration purposes. + DbConnection? connection = null; + DbTransaction? transaction = null; + try + { + connection = await _connectionFactory.CreateConnectionAsync(cancellation); + await connection.OpenAsync(cancellation); + transaction = await connection.BeginTransactionAsync(cancellation); + + return new UnitOfWork(connection, transaction); + } + catch (Exception ex) + { + if (transaction != null) + { + await transaction.DisposeAsync(); + } + + if (connection != null) + { + await connection.DisposeAsync(); + } + + throw; + } + }); +``` + +# Documentation + +Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/Basics/README.md) for more details and examples. diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs index 73fdab0..4974a8d 100644 --- a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs @@ -1,55 +1,65 @@ using System.Collections.Immutable; +using System.ComponentModel; namespace CodeMe.Basics.Threading; /// -/// Basic building block for ambient contexts. Based on . +/// Provides an ambient context for a logical execution flow. /// /// The type of the ambient data. public sealed class ScopedAsyncLocal where T : class { /// - /// Async local scope lifetime. - /// The caller MUST call or there may be a memory leak. + /// Ambient scope instance with partial initialization support. + /// Please DO NOT capture the instance and use instead. /// - internal sealed class Scope : IDisposable + public sealed class Scope : IDisposable { private readonly ScopedAsyncLocal _owner; - private T? _value; - public Scope(ScopedAsyncLocal owner) + internal Scope(ScopedAsyncLocal owner) { _owner = owner; } - public Scope(ScopedAsyncLocal owner, T? value) + internal Scope(ScopedAsyncLocal owner, T? value) : this(owner) { _value = value; IsInitialized = true; } + /// + /// True if the scope was initialized. + /// public bool IsInitialized { get; private set; } - public bool IsDisposed { get; private set; } + internal bool IsDisposed { get; private set; } + /// + /// Value for the initialized scope. + /// + /// Scope was disposed. + /// Scope was not initialized. public T? Value { get { ObjectDisposedException.ThrowIf(IsDisposed, GetType()); - if (!IsInitialized) - { - throw new InvalidOperationException("The scope value is not initialized."); - } - - return _value; + return IsInitialized + ? _value + : throw new InvalidOperationException("The scope value is not initialized."); } } + /// + /// Initializes the scope with specified value. + /// + /// Scope was disposed. + /// Scope was already initialized. public void Initialize(T? value) { ObjectDisposedException.ThrowIf(IsDisposed, GetType()); @@ -63,6 +73,7 @@ public void Initialize(T? value) IsInitialized = true; } + /// public void Dispose() { if (!IsDisposed) @@ -76,25 +87,17 @@ public void Dispose() } } - /// - /// Design decisions: - /// 1. We do support asynchronous initialization. - /// 2. On initialization there is no way to update AsyncLocal's value - /// for the calling method after first await - /// as the continuation is being run using a copy of parent execution context. - /// So, we have to store AsyncLocal's value before initialization. - /// 3. We cannot revert store operation for the parent context so there may be cases - /// when we leave parent context AsyncLocal's value in non-initialized state. - /// 4. It seems the only viable option is to store stack of scopes, - /// to perform cleanup in begin / end scope methods - /// and to take first initialized scope in the Current accessor. - /// + // A stack of scopes is kept to support nested scopes and to restore the previous ambient value when a scope + // is disposed. + // + // Scopes are initialized in two steps because the value of the async-local context must be set before any async + // calls are executed. Otherwise, an update performed after the first await would not be propagated back to the + // caller, because the continuation would run with a copy of the parent execution context. private readonly AsyncLocal> _current; - private readonly bool _validateDisposeOrder; /// - /// Creates ambient context + /// Creates ambient context. /// /// Fail for out-of-order scope dispose. public ScopedAsyncLocal(bool validateDisposeOrder = false) @@ -103,14 +106,6 @@ public ScopedAsyncLocal(bool validateDisposeOrder = false) _validateDisposeOrder = validateDisposeOrder; } - /// - /// The current ambient value. - /// - public T? Current => CurrentScope?.Value; - - /// - /// The current ambient scope. - /// private Scope? CurrentScope { // Returns first initialized scope value or default. @@ -134,6 +129,41 @@ private Scope? CurrentScope } } + /// + /// The current ambient value. + /// + public T? Current => CurrentScope?.Value; + + /// + /// Starts a new scope with uninitialized value. + /// The caller MUST call to set the value + /// and on the end of scope lifetime or there may be a memory leak. + /// This method cannot be called from async method because the scope value will not be propagated back to the caller. + /// Instead, call it from synchronous part, and return task of completion part; + /// + /// + /// ValueTask<IDisposable> BeginCustomScopeAsync()\ + /// { + /// var scope = _scopedAsyncLocal.BeginScopeInitialization(); + /// return CompleteCustomScopeAsync(scope); + /// } + /// async ValueTask<IDisposable> CompleteCustomScopeAsync(ScopedAsyncLocal<string>.Scope scope) + /// { + /// var resource = await GetResourceAsync(); + /// scope.Initialize(resource); + /// return scope; + /// } + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public Scope BeginScopeInitialization() + { + var newScope = new Scope(this); + + PushScope(newScope); + + return newScope; + } + /// /// Begins a new scope with new ambient value. /// The caller MUST call or there may be a memory leak. @@ -151,39 +181,40 @@ public IDisposable BeginScope(T? value) /// /// Begins a new scope with new ambient value. - /// The caller MUST call or there may be a memory leak. + /// IMPORTANT: If the method is called from helper method, + /// the helper should be synchronous and SHOULD NOT contain awaits. + /// Otherwise, updated scope value will not be propagated back to the caller of the helper method. + /// The caller MUST call on the end of scope lifetime or there may be a memory leak. /// /// /// Async factory for the new scope ambient value. Value will be replaced with previous one on /// scope disposal. /// /// to restore the parent scope. - public async Task BeginScopeAsync(Func> valueFactory) + public Task BeginScopeAsync(Func> valueFactory) { - var newScope = new Scope(this); + ArgumentNullException.ThrowIfNull(valueFactory); - PushScope(newScope); + var newScope = BeginScopeInitialization(); + return CompleteScopeAsync(newScope, valueFactory); + } + private async Task CompleteScopeAsync( + Scope newScope, + Func> valueFactory) + { try { var value = await valueFactory(); newScope.Initialize(value); + + return newScope; } - catch (Exception ex) + catch (Exception) { newScope.Dispose(); throw; } - - return newScope; - } - - private void PushScope(Scope newScope) - { - PopDisposedScopes(); - _current.Value = _current.Value is { } stack - ? stack.Push(newScope) - : [newScope]; } private void AssertIsCurrentScope(Scope expected) @@ -195,20 +226,30 @@ private void AssertIsCurrentScope(Scope expected) } } + private void PushScope(Scope newScope) + { + var stack = _current.Value; + _current.Value = stack == null ? [newScope] : PopDisposedScopes(stack).Push(newScope); + } + private void PopDisposedScopes() { - // Check the comment of the _current field for the justification. - if (_current.Value is not { } stack) + if (_current.Value is { } stack) { - return; + stack = PopDisposedScopes(stack); + _current.Value = stack.IsEmpty ? null! : stack; } + } - var originalStack = stack; - while (!stack.IsEmpty && stack.Peek().IsDisposed) stack = stack.Pop(); + private ImmutableStack PopDisposedScopes(ImmutableStack current) + { + var newStack = current; - if (!ReferenceEquals(stack, originalStack)) + while (!newStack.IsEmpty && newStack.Peek().IsDisposed) { - _current.Value = stack; + newStack = newStack.Pop(); } + + return newStack; } } \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors.Abstractions/README.md b/src/CodeMe.ServiceErrors.Abstractions/README.md index 812b0a5..eb93c09 100644 --- a/src/CodeMe.ServiceErrors.Abstractions/README.md +++ b/src/CodeMe.ServiceErrors.Abstractions/README.md @@ -25,4 +25,4 @@ if (error.Matches(descriptor)) # Documentation -Check [documentation](https://github.com/ig-sinicyn/CodeMe) for more details and examples. \ No newline at end of file +Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors/README.md b/src/CodeMe.ServiceErrors/README.md index 593949f..46662b1 100644 --- a/src/CodeMe.ServiceErrors/README.md +++ b/src/CodeMe.ServiceErrors/README.md @@ -84,4 +84,4 @@ internal sealed class OrderNotFoundException : ServiceException # Documentation -Check [documentation](https://github.com/ig-sinicyn/CodeMe) for more details and examples. \ No newline at end of file +Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj b/tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj new file mode 100644 index 0000000..4077e5c --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj @@ -0,0 +1,7 @@ + + + + net10.0 + + + \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs b/tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs new file mode 100644 index 0000000..fcef8c4 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs @@ -0,0 +1,84 @@ +using CodeMe.Basics.Threading; + +namespace CodeMe.Basics.UnitTests; + +public class CustomAsyncLocalScopeTests +{ + public sealed class ResourceScope : IAsyncDisposable + { + internal IDisposable? Scope { get; set; } + + public string Value { get; set; } = null!; + + public ValueTask DisposeAsync() + { + Scope?.Dispose(); + return DisposeCoreAsync(); + } + + private async ValueTask DisposeCoreAsync() + { + await Task.Delay(1); + Value = null!; + } + } + + public sealed class ResourceManager + { + private readonly ScopedAsyncLocal _context = new(validateDisposeOrder: true); + + public ResourceScope? Current => _context.Current; + + public ValueTask BeginScopeAsync(string value) + { + var scope = _context.BeginScopeInitialization(); + return BeginScopeAsyncCore(scope, value); + } + + private async ValueTask BeginScopeAsyncCore( + ScopedAsyncLocal.Scope scope, + string value) + { + await Task.Delay(1); + var result = new ResourceScope + { + Scope = scope, + Value = value + }; + scope.Initialize(result); + return result; + } + } + + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var context = new ResourceManager(); + + // Assert + context.Current.Should().BeNull(); + } + + [Fact] + public async Task Scope_BeginDispose_ShouldBeExpected() + { + // Arrange + var context = new ResourceManager(); + + // Act + string? inScope; + var before = context.Current?.Value; + await using (await context.BeginScopeAsync("Hello!")) + { + inScope = context.Current?.Value; + } + + var after = context.Current?.Value; + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } +} \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs b/tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs new file mode 100644 index 0000000..9bb48a4 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs @@ -0,0 +1,269 @@ +using CodeMe.Basics.Threading; + +namespace CodeMe.Basics.UnitTests; + +public class ScopedAsyncLocalTests +{ + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Assert + local.Current.Should().BeNull(); + } + + [Fact] + public void Scope_BeginDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + string? inScope; + var before = local.Current; + using (local.BeginScope("Hello!")) + { + inScope = local.Current; + } + + var after = local.Current; + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } + + [Fact] + public void ScopeBeforeInitialization_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + using var scope = local.BeginScopeInitialization(); + + // Assert + local.Current.Should().BeNull(); + scope.IsInitialized.Should().BeFalse(); + Assert.Throws(() => scope.Value); + scope.IsDisposed.Should().BeFalse(); + } + + [Fact] + public void ScopeAfterInitialization_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + using var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + + // Assert + local.Current.Should().Be("Hello!"); + scope.IsInitialized.Should().BeTrue(); + scope.Value.Should().Be("Hello!"); + scope.IsDisposed.Should().BeFalse(); + } + + [Fact] + public void ScopeAfterInitializationAndDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + scope.Dispose(); + + // Assert + local.Current.Should().BeNull(); + scope.IsInitialized.Should().BeFalse(); + Assert.Throws(() => scope.Value); + scope.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void ScopeAfterDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Dispose(); + + // Assert + local.Current.Should().BeNull(); + scope.IsInitialized.Should().BeFalse(); + Assert.Throws(() => scope.Value); + scope.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void Scope_NestedBeginDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var before1 = local.Current; + string? inScope1; + string? inScope2; + string? after2; + using (local.BeginScope("Hello!")) + { + inScope1 = local.Current; + + using (local.BeginScope("Hello from nested scope!")) + { + inScope2 = local.Current; + } + + after2 = local.Current; + } + + var after1 = local.Current; + + // Assert + before1.Should().BeNull(); + inScope1.Should().Be("Hello!"); + inScope2.Should().Be("Hello from nested scope!"); + after2.Should().Be("Hello!"); + after1.Should().BeNull(); + } + + [Fact] + public async Task Scope_NestedBeginAsyncDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var before1 = local.Current; + string? inScope1; + string? inScope2; + string? after2; + using (await BeginScopeAsync(local, "Hello!")) + { + inScope1 = local.Current; + + using (await BeginScopeAsync(local, "Hello from nested scope!")) + { + inScope2 = local.Current; + } + + after2 = local.Current; + } + + var after1 = local.Current; + + // Assert + before1.Should().BeNull(); + inScope1.Should().Be("Hello!"); + inScope2.Should().Be("Hello from nested scope!"); + after2.Should().Be("Hello!"); + after1.Should().BeNull(); + } + + [Fact] + public async Task Scope_ShouldNotBackpropagate() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var before = local.Current; + string? inScope = null; + var scope = await Task.Run( + async () => + { + var result = local.BeginScope("Hello!"); + await Task.Delay(1); + inScope = local.Current; + return result; + }); + + var after = local.Current; + scope.Dispose(); + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } + + [Fact] + public void ScopeValidation_ShouldThrow() + { + // Arrange + var local = new ScopedAsyncLocal(validateDisposeOrder: true); + var scopes = new List(); + foreach (var value in new[] { "A", "B", "C" }) + { + scopes.Add(local.BeginScope(value)); + } + + // Act & Assert + Assert.Throws(() => scopes[0].Dispose()); + Assert.Throws(() => scopes[1].Dispose()); + local.Current.Should().Be("C"); + scopes[2].Dispose(); + local.Current.Should().Be("B"); + scopes[1].Dispose(); + local.Current.Should().Be("A"); + scopes[0].Dispose(); + local.Current.Should().BeNull(); + } + + [Fact] + public void ScopeWithoutValidation_ShouldNotThrow() + { + // Arrange + var local = new ScopedAsyncLocal(validateDisposeOrder: false); + var scopes = new List(); + foreach (var value in new[] { "A", "B", "C" }) + { + scopes.Add(local.BeginScope(value)); + } + + // Act & Assert + local.Current.Should().Be("C"); + scopes[1].Dispose(); + local.Current.Should().Be("C"); + scopes[2].Dispose(); + local.Current.Should().Be("A"); + scopes[0].Dispose(); + local.Current.Should().BeNull(); + } + + [Fact] + public async Task ScopeValidation_ShouldThrowOnBackpropagate() + { + // Arrange + var local = new ScopedAsyncLocal(validateDisposeOrder: true); + + // Act & Assert + var scope = await Task.Run( + async () => + { + var result = local.BeginScope("Hello!"); + await Task.Delay(1); + return result; + }); + Assert.Throws(() => scope.Dispose()); + } + + private Task BeginScopeAsync(ScopedAsyncLocal local, string value) => + local.BeginScopeAsync( + async () => + { + await Task.Delay(1); + + return value; + }); +} \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/packages.lock.json b/tests/CodeMe.Basics.UnitTests/packages.lock.json new file mode 100644 index 0000000..053f789 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/packages.lock.json @@ -0,0 +1,139 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AwesomeAssertions": { + "type": "Direct", + "requested": "[9.4.0, )", + "resolved": "9.4.0", + "contentHash": "dJxkWiQ8D+xT6Gr2sSL83+Mar+Vpy2JTcUPxFcckpPJ8VYBfSgnk+zqpS6t7kcGnjz8NLyF14qfuoL4bKzzoew==" + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.2.3, )", + "resolved": "2.2.3", + "contentHash": "9Hot3ty5ZVWHrW40k2NPfD0dCaPwIxj7j7VjujNYwpYkYw9AdbejPHjGNkL/gvUWorauJf5IkeDoUeIbS7LuUg==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.2.3", + "Microsoft.Testing.Platform": "2.2.3" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "S0LJpeMIMrmbVLXDCvPVX47OLk28qBYfGU+5SNCbarOEdw8oKLfiVqaACwuYRvLiOqDEB/+VJ8gTSB1ZwheoOQ==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v2": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "H580BvHyuADoWzlH9zRk5fqVyGucm6mhph+k40CQc9O4ie+Buxa4Pk9Q92BEClqIICqi25J7fuMII9qFYYgKtw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.2.3", + "contentHash": "hntvxJEkmUAx6C2xXc/PO38DqEQl4rimzOgSvTR1hAMruMid7R4RcXOrzzF33J66gKaN7jRaQ0TMW/nNfaV9jw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.2.3" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.2.3", + "contentHash": "LhM1/Qoi8Ams5QcD4r3f09CSOono9iQr3NEJQItFtyzWB55nWTgEOsVqXqMWWWIwk3nkPqc+XfnlJmp8xUI5fg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "2zKkQKaUoaKgb/3AekboWOdLMh4upCo1nLWQnjGzp8r9YjiNOZRrzTsJQ3A4U03AcbH0evlIvFDKYSUqmTVuug==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "zW82tdCm+T1uUD1JKE+SmhgMq8nCAvcFPRLIVEiRgaxBSjcyJEKopLU3bHGOa416q+N3Dz7m1zLoPR5VJ5OQ+Q==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.0.2", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.0.2", + "Microsoft.Testing.Platform": "2.0.2", + "Microsoft.Testing.Platform.MSBuild": "2.0.2", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "codeme.basics": { + "type": "Project" + } + } + } +} \ No newline at end of file From 2be045dd83b1c37f225fdb3e19d19d5e9b86a2f8 Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sat, 11 Jul 2026 12:43:26 +0300 Subject: [PATCH 3/6] Docs for ScopedAsyncLocal --- .../Threading/ScopedAsyncLocal.cs | 156 ++++++++++++++---- 1 file changed, 122 insertions(+), 34 deletions(-) diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs index 4974a8d..b9213a4 100644 --- a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs @@ -5,6 +5,10 @@ namespace CodeMe.Basics.Threading; /// /// Provides an ambient context for a logical execution flow. +/// +/// You SHOULD call on the end of scope lifetime or there may be memory leaks. +/// +/// Please check documentation for using scopes together with async initialization and disposal. /// /// The type of the ambient data. public sealed class ScopedAsyncLocal @@ -12,7 +16,25 @@ public sealed class ScopedAsyncLocal { /// /// Ambient scope instance with partial initialization support. - /// Please DO NOT capture the instance and use instead. + /// You SHOULD call on the end of scope lifetime or there may be memory leaks. + /// It is especially important if you are using scopes inside a loop or recursive calls + /// as you may end with a very long stack of non-disposed scopes. + /// + /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. + /// This minor optimization will remove reference to the disposed scope from calling execution context. + /// + /// + /// A proper implementation that will clear caller's execution context reference to the disposed scope: + /// + /// ValueTask DisposeAsync() + /// { + /// _scope.Dispose(); + /// return CompleteDisposeAsync(); + /// } + /// + /// async ValueTask CompleteDisposeAsync() { ... } + /// + /// /// public sealed class Scope : IDisposable { @@ -41,8 +63,8 @@ internal Scope(ScopedAsyncLocal owner, T? value) /// /// Value for the initialized scope. /// - /// Scope was disposed. /// Scope was not initialized. + /// Scope was disposed. public T? Value { get @@ -58,8 +80,8 @@ public T? Value /// /// Initializes the scope with specified value. /// - /// Scope was disposed. /// Scope was already initialized. + /// Scope was disposed. public void Initialize(T? value) { ObjectDisposedException.ThrowIf(IsDisposed, GetType()); @@ -73,7 +95,28 @@ public void Initialize(T? value) IsInitialized = true; } - /// + /// + /// Closes current scope and restores previous one. + /// You SHOULD call on the end of scope lifetime or there may be memory leaks. + /// It is especially important if you are using scopes inside a loop or recursive calls + /// as you may end with a very long stack of non-disposed scopes. + /// + /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. + /// This minor optimization will remove reference to the disposed scope from calling execution context. + /// + /// + /// A proper implementation that will clear caller's execution context reference to the disposed scope: + /// + /// ValueTask DisposeAsync() + /// { + /// _scope.Dispose(); + /// return CompleteDisposeAsync(); + /// } + /// + /// async ValueTask CompleteDisposeAsync() { ... } + /// + /// + /// public void Dispose() { if (!IsDisposed) @@ -87,19 +130,21 @@ public void Dispose() } } - // A stack of scopes is kept to support nested scopes and to restore the previous ambient value when a scope - // is disposed. + // A stack of scopes is kept to support partial initialization + // and to restore the previous ambient value when a scope is disposed. // - // Scopes are initialized in two steps because the value of the async-local context must be set before any async - // calls are executed. Otherwise, an update performed after the first await would not be propagated back to the - // caller, because the continuation would run with a copy of the parent execution context. + // Scopes are initialized in two steps because AsyncLocal values are stored in current ExecutionContext. + // Async calls create copies of ExecutionContext and any changes in derived contexts are not passed back to the parent execution context. + // Therefore, we start new scope synchronously in the caller execution context. + // The rest of initialization of a new scope may be performed asynchronously. + // The new scope is available to the caller immediately, but we filter out uninitialized scopes. private readonly AsyncLocal> _current; private readonly bool _validateDisposeOrder; /// /// Creates ambient context. /// - /// Fail for out-of-order scope dispose. + /// Fail for out-of-order scope disposal. public ScopedAsyncLocal(bool validateDisposeOrder = false) { _current = new AsyncLocal>(); @@ -136,24 +181,41 @@ private Scope? CurrentScope /// /// Starts a new scope with uninitialized value. - /// The caller MUST call to set the value - /// and on the end of scope lifetime or there may be a memory leak. - /// This method cannot be called from async method because the scope value will not be propagated back to the caller. - /// Instead, call it from synchronous part, and return task of completion part; - /// + /// + /// This method is designed for advanced scenarios and requires some care from the caller. Consider to use + /// or as they are simpler to use. + /// + /// + /// The caller MUST call to set the value of the scope. + /// and on the end of scope lifetime (even for non-initialized scopes) or there may be a memory + /// leak. + /// + /// + /// If you want to pass a new scope back to the caller of your code, you SHOULD NOT use async methods. + /// Scopes are stored as a part of . + /// Async calls create a copy of execution context and do not pass changes back to the parent context. + /// /// - /// ValueTask<IDisposable> BeginCustomScopeAsync()\ - /// { - /// var scope = _scopedAsyncLocal.BeginScopeInitialization(); - /// return CompleteCustomScopeAsync(scope); - /// } - /// async ValueTask<IDisposable> CompleteCustomScopeAsync(ScopedAsyncLocal<string>.Scope scope) - /// { - /// var resource = await GetResourceAsync(); - /// scope.Initialize(resource); - /// return scope; - /// } + /// A proper implementation that will pass the new scope back to the caller of the method: + /// + /// ValueTask<IDisposable> BeginCustomScopeAsync() + /// { + /// // sync part + /// var scope = _scopedAsyncLocal.BeginScopeInitialization(); + /// return CompleteCustomScopeAsync(scope); + /// } + /// + /// async ValueTask<IDisposable> CompleteCustomScopeAsync(ScopedAsyncLocal<string>.Scope scope) + /// { + /// // asynchronous part + /// var resource = await GetResourceAsync(); + /// scope.Initialize(resource); + /// return scope; + /// } + /// /// + /// + /// to complete initialization and to restore the parent scope. [EditorBrowsable(EditorBrowsableState.Advanced)] public Scope BeginScopeInitialization() { @@ -166,7 +228,16 @@ public Scope BeginScopeInitialization() /// /// Begins a new scope with new ambient value. - /// The caller MUST call or there may be a memory leak. + /// + /// The caller MUST call on the end of scope lifetime or there may be a memory leak. + /// + /// + /// If you want to pass a new scope back to the caller of your code, you SHOULD NOT use async methods. + /// Scopes are stored as a part of . + /// Async calls create a copy of execution context and do not pass changes back to the parent context. + /// + /// Consider to use or if you need to initialize the + /// scope asynchronously. /// /// The new scope ambient value. Will be replaced with previous one on scope disposal. /// to restore the parent scope. @@ -181,14 +252,29 @@ public IDisposable BeginScope(T? value) /// /// Begins a new scope with new ambient value. - /// IMPORTANT: If the method is called from helper method, - /// the helper should be synchronous and SHOULD NOT contain awaits. - /// Otherwise, updated scope value will not be propagated back to the caller of the helper method. + /// /// The caller MUST call on the end of scope lifetime or there may be a memory leak. + /// + /// + /// If you want to pass a new scope back to the caller of your code, you SHOULD NOT use async methods. + /// Scopes are stored as a part of . + /// Async calls create a copy of execution context and do not pass changes back to the parent context. + /// + /// + /// A proper implementation that will pass the new scope back to the caller of the method: + /// + /// ValueTask<IDisposable> BeginResourceScopeAsync() + /// { + /// // sync part + /// return _scopedAsyncLocal.BeginScopeAsync(()=> CreateResourceAsync()); + /// } + /// + /// async ValueTask<Resource> CreateResourceAsync() { ... } + /// + /// /// /// - /// Async factory for the new scope ambient value. Value will be replaced with previous one on - /// scope disposal. + /// Async factory for the new scope ambient value. Value will be replaced with previous one on scope disposal. /// /// to restore the parent scope. public Task BeginScopeAsync(Func> valueFactory) @@ -229,7 +315,9 @@ private void AssertIsCurrentScope(Scope expected) private void PushScope(Scope newScope) { var stack = _current.Value; - _current.Value = stack == null ? [newScope] : PopDisposedScopes(stack).Push(newScope); + _current.Value = stack == null + ? ImmutableStack.Create(newScope) + : PopDisposedScopes(stack).Push(newScope); } private void PopDisposedScopes() @@ -241,7 +329,7 @@ private void PopDisposedScopes() } } - private ImmutableStack PopDisposedScopes(ImmutableStack current) + private static ImmutableStack PopDisposedScopes(ImmutableStack current) { var newStack = current; From b34e38da454e5bd2d27b3e448814d9a7f8e0d7db Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sat, 11 Jul 2026 20:29:14 +0300 Subject: [PATCH 4/6] Tests for ScopedAsyncLocal --- .editorconfig | 1 + .../ScopedAsyncLocal.TestAccessor.cs | 48 +++ .../Threading/ScopedAsyncLocal.cs | 236 +++++------ .../{ => Threading}/CustomScopeTests.cs | 72 ++-- .../ScopedAsyncLocalInternalTests.cs | 386 ++++++++++++++++++ .../{ => Threading}/ScopedAsyncLocalTests.cs | 39 +- 6 files changed, 623 insertions(+), 159 deletions(-) create mode 100644 src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs rename tests/CodeMe.Basics.UnitTests/{ => Threading}/CustomScopeTests.cs (89%) create mode 100644 tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs rename tests/CodeMe.Basics.UnitTests/{ => Threading}/ScopedAsyncLocalTests.cs (86%) diff --git a/.editorconfig b/.editorconfig index 615d4c4..1e10e86 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,6 @@ [*.cs] csharp_style_prefer_primary_constructors = false +dotnet_style_prefer_collection_expression = false # ReSharper properties resharper_align_multiline_binary_expressions_chain = false diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs new file mode 100644 index 0000000..15a8e99 --- /dev/null +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs @@ -0,0 +1,48 @@ +using System.Collections.Immutable; + +namespace CodeMe.Basics.Threading; + +public partial class ScopedAsyncLocal +{ + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly record struct TestSnapshot(T? Value, bool IsInitialized, bool IsDisposed) + { + public static TestSnapshot Uninitialized => new(null, false, false); + public static TestSnapshot Disposed => new(null, false, true); + + public TestSnapshot(Scope scope) + : this( + scope.IsInitialized ? scope.Value : null, + scope.IsInitialized, + scope.IsDisposed) + { + } + + public TestSnapshot(T value) + : this( + value, + true, + false) + { + } + } + + internal readonly struct TestAccessor(ScopedAsyncLocal owner) + { + private ImmutableStack Scopes => + owner._current.Value switch + { + null => [], + { IsEmpty: true } => throw new InvalidOperationException("Inner stack should be null"), + var x => x + }; + + public TestSnapshot? GetSnapshot() => + Scopes.Select(x => (TestSnapshot?)new TestSnapshot(x)).FirstOrDefault(); + + public TestSnapshot GetSingleSnapshot() => Scopes.Select(x => new TestSnapshot(x)).Single(); + + public TestSnapshot[] GetSnapshots() => Scopes.Select(x => new TestSnapshot(x)).ToArray(); + } +} \ No newline at end of file diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs index b9213a4..f912465 100644 --- a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs @@ -11,125 +11,9 @@ namespace CodeMe.Basics.Threading; /// Please check documentation for using scopes together with async initialization and disposal. /// /// The type of the ambient data. -public sealed class ScopedAsyncLocal +public sealed partial class ScopedAsyncLocal where T : class { - /// - /// Ambient scope instance with partial initialization support. - /// You SHOULD call on the end of scope lifetime or there may be memory leaks. - /// It is especially important if you are using scopes inside a loop or recursive calls - /// as you may end with a very long stack of non-disposed scopes. - /// - /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. - /// This minor optimization will remove reference to the disposed scope from calling execution context. - /// - /// - /// A proper implementation that will clear caller's execution context reference to the disposed scope: - /// - /// ValueTask DisposeAsync() - /// { - /// _scope.Dispose(); - /// return CompleteDisposeAsync(); - /// } - /// - /// async ValueTask CompleteDisposeAsync() { ... } - /// - /// - /// - public sealed class Scope : IDisposable - { - private readonly ScopedAsyncLocal _owner; - private T? _value; - - internal Scope(ScopedAsyncLocal owner) - { - _owner = owner; - } - - internal Scope(ScopedAsyncLocal owner, T? value) - : this(owner) - { - _value = value; - IsInitialized = true; - } - - /// - /// True if the scope was initialized. - /// - public bool IsInitialized { get; private set; } - - internal bool IsDisposed { get; private set; } - - /// - /// Value for the initialized scope. - /// - /// Scope was not initialized. - /// Scope was disposed. - public T? Value - { - get - { - ObjectDisposedException.ThrowIf(IsDisposed, GetType()); - - return IsInitialized - ? _value - : throw new InvalidOperationException("The scope value is not initialized."); - } - } - - /// - /// Initializes the scope with specified value. - /// - /// Scope was already initialized. - /// Scope was disposed. - public void Initialize(T? value) - { - ObjectDisposedException.ThrowIf(IsDisposed, GetType()); - - if (IsInitialized) - { - throw new InvalidOperationException("The scope value is already initialized."); - } - - _value = value; - IsInitialized = true; - } - - /// - /// Closes current scope and restores previous one. - /// You SHOULD call on the end of scope lifetime or there may be memory leaks. - /// It is especially important if you are using scopes inside a loop or recursive calls - /// as you may end with a very long stack of non-disposed scopes. - /// - /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. - /// This minor optimization will remove reference to the disposed scope from calling execution context. - /// - /// - /// A proper implementation that will clear caller's execution context reference to the disposed scope: - /// - /// ValueTask DisposeAsync() - /// { - /// _scope.Dispose(); - /// return CompleteDisposeAsync(); - /// } - /// - /// async ValueTask CompleteDisposeAsync() { ... } - /// - /// - /// - public void Dispose() - { - if (!IsDisposed) - { - _owner.AssertIsCurrentScope(this); - _value = null; - IsInitialized = false; - IsDisposed = true; - _owner.PopDisposedScopes(); - } - } - } - // A stack of scopes is kept to support partial initialization // and to restore the previous ambient value when a scope is disposed. // @@ -285,7 +169,7 @@ public Task BeginScopeAsync(Func> valueFactory) return CompleteScopeAsync(newScope, valueFactory); } - private async Task CompleteScopeAsync( + private static async Task CompleteScopeAsync( Scope newScope, Func> valueFactory) { @@ -340,4 +224,120 @@ private static ImmutableStack PopDisposedScopes(ImmutableStack cur return newStack; } + + /// + /// Ambient scope instance with partial initialization support. + /// You SHOULD call on the end of scope lifetime or there may be memory leaks. + /// It is especially important if you are using scopes inside a loop or recursive calls + /// as you may end with a very long stack of non-disposed scopes. + /// + /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. + /// This minor optimization will remove reference to the disposed scope from calling execution context. + /// + /// + /// A proper implementation that will clear caller's execution context reference to the disposed scope: + /// + /// ValueTask DisposeAsync() + /// { + /// _scope.Dispose(); + /// return CompleteDisposeAsync(); + /// } + /// + /// async ValueTask CompleteDisposeAsync() { ... } + /// + /// + /// + public sealed class Scope : IDisposable + { + private readonly ScopedAsyncLocal _owner; + private T? _value; + + internal Scope(ScopedAsyncLocal owner) + { + _owner = owner; + } + + internal Scope(ScopedAsyncLocal owner, T? value) + : this(owner) + { + _value = value; + IsInitialized = true; + } + + /// + /// True if the scope was initialized. + /// + public bool IsInitialized { get; private set; } + + internal bool IsDisposed { get; private set; } + + /// + /// Value for the initialized scope. + /// + /// Scope was not initialized. + /// Scope was disposed. + public T? Value + { + get + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + return IsInitialized + ? _value + : throw new InvalidOperationException("The scope value is not initialized."); + } + } + + /// + /// Initializes the scope with specified value. + /// + /// Scope was already initialized. + /// Scope was disposed. + public void Initialize(T? value) + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + if (IsInitialized) + { + throw new InvalidOperationException("The scope value is already initialized."); + } + + _value = value; + IsInitialized = true; + } + + /// + /// Closes current scope and restores previous one. + /// You SHOULD call on the end of scope lifetime or there may be memory leaks. + /// It is especially important if you are using scopes inside a loop or recursive calls + /// as you may end with a very long stack of non-disposed scopes. + /// + /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. + /// This minor optimization will remove reference to the disposed scope from calling execution context. + /// + /// + /// A proper implementation that will clear caller's execution context reference to the disposed scope: + /// + /// ValueTask DisposeAsync() + /// { + /// _scope.Dispose(); + /// return CompleteDisposeAsync(); + /// } + /// + /// async ValueTask CompleteDisposeAsync() { ... } + /// + /// + /// + public void Dispose() + { + if (!IsDisposed) + { + _owner.AssertIsCurrentScope(this); + _value = null; + IsInitialized = false; + IsDisposed = true; + _owner.PopDisposedScopes(); + } + } + } } \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs similarity index 89% rename from tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs rename to tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs index fcef8c4..bcd4ea9 100644 --- a/tests/CodeMe.Basics.UnitTests/CustomScopeTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs @@ -1,10 +1,42 @@ using CodeMe.Basics.Threading; -namespace CodeMe.Basics.UnitTests; +namespace CodeMe.Basics.UnitTests.Threading; public class CustomAsyncLocalScopeTests { - public sealed class ResourceScope : IAsyncDisposable + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var context = new ResourceManager(); + + // Assert + context.Current.Should().BeNull(); + } + + [Fact] + public async Task Scope_BeginDispose_ShouldBeExpected() + { + // Arrange + var context = new ResourceManager(); + + // Act + string? inScope; + var before = context.Current?.Value; + await using (await context.BeginScopeAsync("Hello!")) + { + inScope = context.Current?.Value; + } + + var after = context.Current?.Value; + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } + + private sealed class ResourceScope : IAsyncDisposable { internal IDisposable? Scope { get; set; } @@ -23,7 +55,7 @@ private async ValueTask DisposeCoreAsync() } } - public sealed class ResourceManager + private sealed class ResourceManager { private readonly ScopedAsyncLocal _context = new(validateDisposeOrder: true); @@ -35,7 +67,7 @@ public ValueTask BeginScopeAsync(string value) return BeginScopeAsyncCore(scope, value); } - private async ValueTask BeginScopeAsyncCore( + private static async ValueTask BeginScopeAsyncCore( ScopedAsyncLocal.Scope scope, string value) { @@ -49,36 +81,4 @@ private async ValueTask BeginScopeAsyncCore( return result; } } - - [Fact] - public void EmptyScope_ShouldBeNull() - { - // Arrange - var context = new ResourceManager(); - - // Assert - context.Current.Should().BeNull(); - } - - [Fact] - public async Task Scope_BeginDispose_ShouldBeExpected() - { - // Arrange - var context = new ResourceManager(); - - // Act - string? inScope; - var before = context.Current?.Value; - await using (await context.BeginScopeAsync("Hello!")) - { - inScope = context.Current?.Value; - } - - var after = context.Current?.Value; - - // Assert - before.Should().BeNull(); - inScope.Should().Be("Hello!"); - after.Should().BeNull(); - } } \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs new file mode 100644 index 0000000..e37af02 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs @@ -0,0 +1,386 @@ +using CodeMe.Basics.Threading; + +namespace CodeMe.Basics.UnitTests.Threading; + +using TestSnapshot = ScopedAsyncLocal.TestSnapshot; + +public class ScopedAsyncLocalInternalTests +{ + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void Scope_BeginInitialize_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope = local.BeginScopeInitialization(); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().Be(TestSnapshot.Uninitialized); + } + + [Fact] + public void Scope_Initialize_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().Be(new TestSnapshot("Hello!")); + } + + [Fact] + public void Scope_BeginInitializeDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void Scope_InitializeDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void Scope_BeginScope_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope = local.BeginScope("Hello!"); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().Be(new TestSnapshot("Hello!")); + } + + [Fact] + public void Scope_BeginScopeDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = local.BeginScope("Hello!"); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public async Task Scope_BeginScopeAsync_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + + using var scope = await local.BeginScopeAsync( + async () => + { + await Task.Delay(1); + return "Hello!"; + }); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().Be(new TestSnapshot("Hello!")); + } + + [Fact] + public async Task Scope_BeginScopeAsyncDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = await local.BeginScopeAsync( + async () => + { + await Task.Delay(1); + return "Hello!"; + }); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void NestedScopes_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope1 = local.BeginScope("Hello!"); + using var scope2 = local.BeginScope("Hello from nested scope!"); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().Be("Hello from nested scope!"); + current.Should().BeEquivalentTo( + [ + new TestSnapshot("Hello!"), + new TestSnapshot("Hello from nested scope!") + ]); + } + + [Fact] + public void NestedScopes_SingleDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope1 = local.BeginScope("Hello!"); + var scope2 = local.BeginScope("Hello from nested scope!"); + scope2.Dispose(); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().BeEquivalentTo( + [ + new TestSnapshot("Hello!") + ]); + } + + [Fact] + public void NestedScopes_OutOfOrderDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope1 = local.BeginScope("Hello!"); + using var scope2 = local.BeginScope("Hello from nested scope!"); + scope1.Dispose(); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().Be("Hello from nested scope!"); + current.Should().BeEquivalentTo( + [ + TestSnapshot.Disposed, + new TestSnapshot("Hello from nested scope!") + ]); + } + + [Fact] + public void NestedScopes_FullDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope1 = local.BeginScope("Hello!"); + var scope2 = local.BeginScope("Hello from nested scope!"); + scope1.Dispose(); + scope2.Dispose(); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeEmpty(); + } + + [Fact] + public async Task BeginScopeAsync_FullAsyncPath_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var beforeCall = internals.GetSnapshot(); + + var inInit = TestSnapshot.Disposed; + var task = local.BeginScopeAsync( + async () => + { + await completion.Task; + inInit = internals.GetSingleSnapshot(); + return "Hello!"; + }); + var afterCall = internals.GetSingleSnapshot(); + + completion.SetResult(); + var scope = await task; + var afterAwait = internals.GetSingleSnapshot(); + + scope.Dispose(); + var afterDispose = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeCall.Should().BeNull(); + inInit.Should().Be(TestSnapshot.Uninitialized); + afterCall.Should().Be(TestSnapshot.Uninitialized); + afterAwait.Should().Be(new TestSnapshot("Hello!")); + afterDispose.Should().BeNull(); + } + + [Fact] + public async Task BeginScopeAsyncAndFail_FullAsyncPath_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var beforeCall = internals.GetSnapshot(); + + var inInit = TestSnapshot.Disposed; + var task = local.BeginScopeAsync( + async () => + { + await completion.Task; + inInit = internals.GetSingleSnapshot(); + throw new InvalidOperationException(); + }); + var afterCall = internals.GetSingleSnapshot(); + + completion.SetResult(); + await Assert.ThrowsAsync(() => task); + var afterAwait = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeCall.Should().BeNull(); + inInit.Should().Be(TestSnapshot.Uninitialized); + afterCall.Should().Be(TestSnapshot.Uninitialized); + afterAwait.Should().Be(TestSnapshot.Disposed); + } + + [Fact] + public async Task DisposeScopeAsync_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var scope = local.BeginScope("Hello!"); + var beforeDispose = internals.GetSingleSnapshot(); + + var inAsyncDispose = TestSnapshot.Uninitialized; + var inAsyncAfterDispose = (TestSnapshot?)TestSnapshot.Uninitialized; + var task = Task.Run( + async () => + { + await completion.Task; + inAsyncDispose = internals.GetSingleSnapshot(); + scope.Dispose(); + inAsyncAfterDispose = internals.GetSnapshot(); + }, + TestContext.Current.CancellationToken); + var afterCall = internals.GetSingleSnapshot(); + + completion.SetResult(); + await task; + var afterAwait = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeDispose.Should().Be(new TestSnapshot("Hello!")); + inAsyncDispose.Should().Be(new TestSnapshot("Hello!")); + inAsyncAfterDispose.Should().BeNull(); + afterCall.Should().Be(new TestSnapshot("Hello!")); + afterAwait.Should().Be(TestSnapshot.Disposed); + } + + [Fact] + public async Task DisposedScopes_ShouldBeRemoved() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var disposedAsyncScope = local.BeginScope("Hello!"); + await Task.Run( + async () => + { + await Task.Delay(1); + disposedAsyncScope.Dispose(); + }, + TestContext.Current.CancellationToken); + + // Act + var beforeNewScope = internals.GetSingleSnapshot(); + var scope = local.BeginScope("Hello-2!"); + var afterNewScope = internals.GetSingleSnapshot(); + scope.Dispose(); + var afterDispose = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeNewScope.Should().Be(TestSnapshot.Disposed); + afterNewScope.Should().Be(new TestSnapshot("Hello-2!")); + afterDispose.Should().BeNull(); + } +} \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs similarity index 86% rename from tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs rename to tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs index 9bb48a4..7ced292 100644 --- a/tests/CodeMe.Basics.UnitTests/ScopedAsyncLocalTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs @@ -1,6 +1,6 @@ using CodeMe.Basics.Threading; -namespace CodeMe.Basics.UnitTests; +namespace CodeMe.Basics.UnitTests.Threading; public class ScopedAsyncLocalTests { @@ -67,6 +67,7 @@ public void ScopeAfterInitialization_ShouldBeExpected() scope.IsInitialized.Should().BeTrue(); scope.Value.Should().Be("Hello!"); scope.IsDisposed.Should().BeFalse(); + Assert.Throws(() => scope.Initialize("Hello!")); } [Fact] @@ -85,6 +86,7 @@ public void ScopeAfterInitializationAndDispose_ShouldBeExpected() scope.IsInitialized.Should().BeFalse(); Assert.Throws(() => scope.Value); scope.IsDisposed.Should().BeTrue(); + Assert.Throws(() => scope.Initialize("Hello!")); } [Fact] @@ -171,7 +173,7 @@ public async Task Scope_NestedBeginAsyncDispose_ShouldBeExpected() } [Fact] - public async Task Scope_ShouldNotBackpropagate() + public async Task Scope_DoesNotReturnFromAsyncCall() { // Arrange var local = new ScopedAsyncLocal(); @@ -186,7 +188,8 @@ public async Task Scope_ShouldNotBackpropagate() await Task.Delay(1); inScope = local.Current; return result; - }); + }, + TestContext.Current.CancellationToken); var after = local.Current; scope.Dispose(); @@ -197,6 +200,31 @@ public async Task Scope_ShouldNotBackpropagate() after.Should().BeNull(); } + [Fact] + public async Task ScopeDispose_AppliedFromAsyncCall() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + + var scope = local.BeginScope("Hello!"); + var before = local.Current; + await Task.Run( + async () => + { + await Task.Delay(1); + scope.Dispose(); + }, + TestContext.Current.CancellationToken); + + var after = local.Current; + + // Assert + before.Should().Be("Hello!"); + after.Should().BeNull(); + } + [Fact] public void ScopeValidation_ShouldThrow() { @@ -254,11 +282,12 @@ public async Task ScopeValidation_ShouldThrowOnBackpropagate() var result = local.BeginScope("Hello!"); await Task.Delay(1); return result; - }); + }, + TestContext.Current.CancellationToken); Assert.Throws(() => scope.Dispose()); } - private Task BeginScopeAsync(ScopedAsyncLocal local, string value) => + private static Task BeginScopeAsync(ScopedAsyncLocal local, string value) => local.BeginScopeAsync( async () => { From 70ca7363d3b24c88c3adf105fc87587b900d40d4 Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sun, 12 Jul 2026 12:53:30 +0300 Subject: [PATCH 5/6] Docs update --- README.md | 18 ++++++++++-- docs/Basics/AsyncLocal/README.md | 13 +++++++++ docs/ServiceErrors/README.md | 4 --- src/CodeMe.ServiceErrors/README.md | 45 +++++++++++++++++++----------- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b30d998..f31ab70 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,11 @@ CodeMe is a set of small, focused, reusable libraries aimed to reduce amount of # CodeMe.ServiceErrors -CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. +CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. +## Minimal example + +Well-known errors, testing for errors, conversions and DI registration: ```csharp using CodeMe.ServiceErrors; using CodeMe.ServiceErrors.DependencyInjection; @@ -13,14 +16,23 @@ using CodeMe.ServiceErrors.Serializable; using Microsoft.Extensions.DependencyInjection; using static WellKnownOrderApiErrors; +// DI registration var services = new ServiceCollection(); services .AddServiceErrors(RootGroup) .Add(typeof(WellKnownOrderApiErrors)); - using var provider = services.BuildServiceProvider(); var errorFactory = provider.GetRequiredService(); +// Error return and handling +var error = new ServiceError(OrderNotFound, "Order 42 was not found"); +// ... +if (error.Matches(OrderNotFound)) +{ + // handle the error +} + +// Error serialization and exception factory ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); ServiceErrorDto dto = errorFactory.CreateDto(error); ServiceError errorFromDto = errorFactory.CreateError(dto); @@ -38,6 +50,7 @@ ServiceError errorFromDto = errorFactory.CreateError(dto); IServiceException exception = errorFactory.CreateException(errorFromDto); ServiceError errorFromException = exception.Error; +// Well-known errors declaration [ServiceErrors] internal static class WellKnownOrderApiErrors { @@ -50,6 +63,7 @@ internal static class WellKnownOrderApiErrors ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); } +// Typed exceptions internal sealed class OrderNotFoundException : ServiceException { public OrderNotFoundException(ServiceError error) diff --git a/docs/Basics/AsyncLocal/README.md b/docs/Basics/AsyncLocal/README.md index 013efba..3011ff8 100644 --- a/docs/Basics/AsyncLocal/README.md +++ b/docs/Basics/AsyncLocal/README.md @@ -3,6 +3,19 @@ `ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. +## How it works + +`ScopedAsyncLocal` provides ambient context by keeping a stack of scopes for the current execution flow. Each scope represents a logical boundary such as a request, unit-of-work, or tenant context. When you call `BeginScope` or `BeginScopeAsync`, a new scope is pushed onto the current execution context, and `Current` resolves to the value from the innermost initialized scope. + +Nested scopes override the parent value while they are active. When the child scope is disposed, the previous ambient value is restored automatically, so the value behaves like a flow-scoped context without having to pass it through every method call. + +The implementation uses `AsyncLocal` under the hood, which means the value is captured per async flow. Because async calls create copies of the execution context, initialization is split into two steps: + +- the scope is created synchronously so it can be observed immediately by the caller; +- the scope value is initialized later, and uninitialized scopes are ignored while resolving `Current`. + +This design lets the new scope be available to the caller right away, while still supporting asynchronous value factories. The library also keeps a stack of scopes so it can restore the previous value when a scope is disposed. If you forget to dispose scopes, the stack can grow and hold references longer than intended. When `validateDisposeOrder: true` is used, disposing scopes in the wrong order throws an exception. + ## Scenarios and example of usage Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. diff --git a/docs/ServiceErrors/README.md b/docs/ServiceErrors/README.md index 002235e..65face7 100644 --- a/docs/ServiceErrors/README.md +++ b/docs/ServiceErrors/README.md @@ -1,7 +1,3 @@ -# CodeMe - -CodeMe is a set of small, focused, reusable libraries aimed to reduce amount of boring boilerplate code in .NET applications. - # CodeMe.ServiceErrors CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. diff --git a/src/CodeMe.ServiceErrors/README.md b/src/CodeMe.ServiceErrors/README.md index 46662b1..c87228b 100644 --- a/src/CodeMe.ServiceErrors/README.md +++ b/src/CodeMe.ServiceErrors/README.md @@ -2,28 +2,28 @@ CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. -## Minimal example +## Introduction -Handling the errors: -```csharp -using CodeMe.ServiceErrors; +The core model is built around a few simple concepts: -// Error URI: problem://orders-api/orders/order-not-found, status 404 -var descriptor = ErrorDescriptor.NotFound( - group: ErrorGroupUri.Create("problem", "orders-api", "orders"), - code: "order-not-found"); +* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. +* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. +* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). +* `ServiceErrorDto` represents serializable service error format. +* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. +* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. -var error = new ServiceError(descriptor, "Order 66 was not found"); +Typical usage scenarios include: -// ... +* Enforcing usage of well-known domain errors. +* Exposing a predictable error contract from HTTP APIs or gRPC services. +* Registering error definitions in DI so services can create and rehydrate errors consistently. +* Passing errors across process boundaries using a serializable error payload. +* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. -if (error.Matches(descriptor)) -{ - // handle the error -} -``` +### Minimal example -Well-known errors, conversions and DI registration: +Well-known errors, testing for errors, conversions and DI registration: ```csharp using CodeMe.ServiceErrors; using CodeMe.ServiceErrors.DependencyInjection; @@ -31,14 +31,23 @@ using CodeMe.ServiceErrors.Serializable; using Microsoft.Extensions.DependencyInjection; using static WellKnownOrderApiErrors; +// DI registration var services = new ServiceCollection(); services .AddServiceErrors(RootGroup) .Add(typeof(WellKnownOrderApiErrors)); - using var provider = services.BuildServiceProvider(); var errorFactory = provider.GetRequiredService(); +// Error return and handling +var error = new ServiceError(OrderNotFound, "Order 42 was not found"); +// ... +if (error.Matches(OrderNotFound)) +{ + // handle the error +} + +// Error serialization and exception factory ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); ServiceErrorDto dto = errorFactory.CreateDto(error); ServiceError errorFromDto = errorFactory.CreateError(dto); @@ -56,6 +65,7 @@ ServiceError errorFromDto = errorFactory.CreateError(dto); IServiceException exception = errorFactory.CreateException(errorFromDto); ServiceError errorFromException = exception.Error; +// Well-known errors declaration [ServiceErrors] internal static class WellKnownOrderApiErrors { @@ -68,6 +78,7 @@ internal static class WellKnownOrderApiErrors ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); } +// Typed exceptions internal sealed class OrderNotFoundException : ServiceException { public OrderNotFoundException(ServiceError error) From 0b5f34b7af022bbc04e97ff653887b235259dc9a Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sun, 12 Jul 2026 12:57:50 +0300 Subject: [PATCH 6/6] Cleanup on review --- docs/Basics/AsyncLocal/README.md | 2 +- src/CodeMe.Basics/CodeMe.Basics.csproj | 12 ++++++------ .../CodeMe.ServiceErrors.Abstractions.csproj | 1 - src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj | 1 - 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/Basics/AsyncLocal/README.md b/docs/Basics/AsyncLocal/README.md index 3011ff8..5ef91f2 100644 --- a/docs/Basics/AsyncLocal/README.md +++ b/docs/Basics/AsyncLocal/README.md @@ -39,7 +39,7 @@ using (context.BeginScope("request-1")) Console.WriteLine(context.Current); // null ``` -n t + `ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. ```csharp diff --git a/src/CodeMe.Basics/CodeMe.Basics.csproj b/src/CodeMe.Basics/CodeMe.Basics.csproj index b760144..9aff5c0 100644 --- a/src/CodeMe.Basics/CodeMe.Basics.csproj +++ b/src/CodeMe.Basics/CodeMe.Basics.csproj @@ -1,9 +1,9 @@  - - net10.0 - enable - enable - + + net10.0 + Library or simple reusable infrastructure types (scoped async local etc). + AsyncLocal;ScopedAsyncLocal + - + \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj b/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj index 9199871..b97e967 100644 --- a/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj +++ b/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj @@ -4,7 +4,6 @@ netstandard2.1 CodeMe.ServiceErrors latest - Library for typed service errors (Problem Details-alike DTOs) errors;typed errors diff --git a/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj b/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj index 3ae79da..8cd33c4 100644 --- a/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj +++ b/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj @@ -2,7 +2,6 @@ net10.0 - Library for typed service errors (Problem Details-alike DTOs) errors;typed errors