From d56f29e90dbc68323073a95d7cbdd157c52fab55 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:41:33 -0400 Subject: [PATCH 1/2] feat: add IAsyncDisposable/IDisposable to base classes (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtractorBase, LoaderBase, and TransformerBase now implement IAsyncDisposable and IDisposable with the standard dispose pattern and a default no-op: - virtual ValueTask DisposeAsync() / void Dispose() -> Dispose(true) + SuppressFinalize - protected virtual void Dispose(bool disposing) — base marks disposed (idempotent); derived classes override to release streams/connections/etc. Every component now works with 'await using' out of the box, and base-class stages are disposable via the #133 DisposeStagesOnCompletion path. MINOR / source-compatible but BINARY-BREAKING (adds interfaces to the base classes — consumers recompile; subclasses that already implement IDisposable switch to 'override'). Call out in 0.14.0 release notes. PublicAPI.Shipped.txt regenerated. Verified: full Release build clean across all TFMs (0 warnings); 239 tests pass (5 new disposal tests). Co-Authored-By: Claude Opus 4.8 --- .../ExtractorBase.cs | 55 ++++++++- src/Wolfgang.Etl.Abstractions/LoaderBase.cs | 54 ++++++++- .../PublicAPI.Shipped.txt | 9 ++ .../TransformerBase.cs | 54 ++++++++- .../BaseClassTests/AsyncDisposableTests.cs | 106 ++++++++++++++++++ 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs diff --git a/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs b/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs index 3b3f17d..7dc009a 100644 --- a/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs +++ b/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; @@ -17,7 +18,9 @@ namespace Wolfgang.Etl.Abstractions; /// The type of the object being extracted /// The type of the progress object public abstract class ExtractorBase - : IExtractWithProgressAndCancellationAsync + : IExtractWithProgressAndCancellationAsync, + IAsyncDisposable, + IDisposable where TSource : notnull where TProgress : notnull { @@ -25,6 +28,7 @@ public abstract class ExtractorBase private int _currentSkippedItemCount; private long _startTimestamp; private DateTimeOffset _startedAtUtc; + private bool _disposed; @@ -417,4 +421,53 @@ private void EnsureStarted() _startedAtUtc = now; } } + + + + /// + /// Asynchronously releases the resources held by this extractor. The base implementation is a + /// no-op (the base owns no unmanaged resources); derived classes that hold resources such as + /// streams or connections override to release them. Enables + /// await using on any extractor. + /// + /// A completed for the default no-op implementation. + public virtual ValueTask DisposeAsync() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + return default; + } + + + + /// + /// Releases the resources held by this extractor. The base implementation is a no-op; derived + /// classes that hold resources override . + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + + + /// + /// Releases resources held by this extractor. Override in a derived class to dispose resources + /// it owns (streams, connections, etc.), then call base.Dispose(disposing). The base + /// implementation only marks the instance disposed and is idempotent. + /// + /// + /// when called from or + /// (dispose managed resources); when called from a finalizer. + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + } } diff --git a/src/Wolfgang.Etl.Abstractions/LoaderBase.cs b/src/Wolfgang.Etl.Abstractions/LoaderBase.cs index e276c3b..2159a41 100644 --- a/src/Wolfgang.Etl.Abstractions/LoaderBase.cs +++ b/src/Wolfgang.Etl.Abstractions/LoaderBase.cs @@ -17,7 +17,9 @@ namespace Wolfgang.Etl.Abstractions; /// The type of the destination object being written /// The type of the progress object public abstract class LoaderBase - : ILoadWithProgressAndCancellationAsync + : ILoadWithProgressAndCancellationAsync, + IAsyncDisposable, + IDisposable where TDestination : notnull where TProgress : notnull { @@ -25,6 +27,7 @@ public abstract class LoaderBase private int _currentSkippedItemCount; private long _startTimestamp; private DateTimeOffset _startedAtUtc; + private bool _disposed; @@ -416,4 +419,53 @@ private void EnsureStarted() _startedAtUtc = now; } } + + + + /// + /// Asynchronously releases the resources held by this loader. The base implementation is a + /// no-op (the base owns no unmanaged resources); derived classes that hold resources such as + /// connections or streams override to release them. Enables + /// await using on any loader. + /// + /// A completed for the default no-op implementation. + public virtual ValueTask DisposeAsync() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + return default; + } + + + + /// + /// Releases the resources held by this loader. The base implementation is a no-op; derived + /// classes that hold resources override . + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + + + /// + /// Releases resources held by this loader. Override in a derived class to dispose resources + /// it owns (connections, streams, etc.), then call base.Dispose(disposing). The base + /// implementation only marks the instance disposed and is idempotent. + /// + /// + /// when called from or + /// (dispose managed resources); when called from a finalizer. + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + } } diff --git a/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt b/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt index dba653a..bb2ac40 100644 --- a/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt +++ b/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt @@ -2,6 +2,7 @@ Wolfgang.Etl.Abstractions.ExtractorBase Wolfgang.Etl.Abstractions.ExtractorBase.CurrentItemCount.get -> int Wolfgang.Etl.Abstractions.ExtractorBase.CurrentSkippedItemCount.get -> int +Wolfgang.Etl.Abstractions.ExtractorBase.Dispose() -> void Wolfgang.Etl.Abstractions.ExtractorBase.Elapsed.get -> System.TimeSpan Wolfgang.Etl.Abstractions.ExtractorBase.ExtractorBase() -> void Wolfgang.Etl.Abstractions.ExtractorBase.IncrementCurrentItemCount() -> void @@ -73,6 +74,7 @@ Wolfgang.Etl.Abstractions.ITransformWithProgressAsync Wolfgang.Etl.Abstractions.LoaderBase.CurrentItemCount.get -> int Wolfgang.Etl.Abstractions.LoaderBase.CurrentSkippedItemCount.get -> int +Wolfgang.Etl.Abstractions.LoaderBase.Dispose() -> void Wolfgang.Etl.Abstractions.LoaderBase.Elapsed.get -> System.TimeSpan Wolfgang.Etl.Abstractions.LoaderBase.IncrementCurrentItemCount() -> void Wolfgang.Etl.Abstractions.LoaderBase.IncrementCurrentSkippedItemCount() -> void @@ -100,6 +102,7 @@ Wolfgang.Etl.Abstractions.Report.TotalItemCount.init -> void Wolfgang.Etl.Abstractions.TransformerBase Wolfgang.Etl.Abstractions.TransformerBase.CurrentItemCount.get -> int Wolfgang.Etl.Abstractions.TransformerBase.CurrentSkippedItemCount.get -> int +Wolfgang.Etl.Abstractions.TransformerBase.Dispose() -> void Wolfgang.Etl.Abstractions.TransformerBase.Elapsed.get -> System.TimeSpan Wolfgang.Etl.Abstractions.TransformerBase.IncrementCurrentItemCount() -> void Wolfgang.Etl.Abstractions.TransformerBase.IncrementCurrentSkippedItemCount() -> void @@ -122,16 +125,22 @@ static Wolfgang.Etl.Abstractions.Pipeline.Extract(Wolfgang.E static Wolfgang.Etl.Abstractions.Pipeline.Extract(Wolfgang.Etl.Abstractions.IExtractAsync! extractor) -> Wolfgang.Etl.Abstractions.IExtractStage! static Wolfgang.Etl.Abstractions.Pipeline.Extract(Wolfgang.Etl.Abstractions.IExtractWithCancellationAsync! extractor) -> Wolfgang.Etl.Abstractions.IExtractStage! virtual Wolfgang.Etl.Abstractions.ExtractorBase.CreateProgressTimer(System.IProgress! progress) -> Wolfgang.Etl.Abstractions.IProgressTimer! +virtual Wolfgang.Etl.Abstractions.ExtractorBase.Dispose(bool disposing) -> void +virtual Wolfgang.Etl.Abstractions.ExtractorBase.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual Wolfgang.Etl.Abstractions.ExtractorBase.ExtractAsync() -> System.Collections.Generic.IAsyncEnumerable! virtual Wolfgang.Etl.Abstractions.ExtractorBase.ExtractAsync(System.IProgress! progress) -> System.Collections.Generic.IAsyncEnumerable! virtual Wolfgang.Etl.Abstractions.ExtractorBase.ExtractAsync(System.IProgress! progress, System.Threading.CancellationToken token) -> System.Collections.Generic.IAsyncEnumerable! virtual Wolfgang.Etl.Abstractions.ExtractorBase.ExtractAsync(System.Threading.CancellationToken token) -> System.Collections.Generic.IAsyncEnumerable! virtual Wolfgang.Etl.Abstractions.LoaderBase.CreateProgressTimer(System.IProgress! progress) -> Wolfgang.Etl.Abstractions.IProgressTimer! +virtual Wolfgang.Etl.Abstractions.LoaderBase.Dispose(bool disposing) -> void +virtual Wolfgang.Etl.Abstractions.LoaderBase.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual Wolfgang.Etl.Abstractions.LoaderBase.LoadAsync(System.Collections.Generic.IAsyncEnumerable! items) -> System.Threading.Tasks.Task! virtual Wolfgang.Etl.Abstractions.LoaderBase.LoadAsync(System.Collections.Generic.IAsyncEnumerable! items, System.IProgress! progress) -> System.Threading.Tasks.Task! virtual Wolfgang.Etl.Abstractions.LoaderBase.LoadAsync(System.Collections.Generic.IAsyncEnumerable! items, System.IProgress! progress, System.Threading.CancellationToken token) -> System.Threading.Tasks.Task! virtual Wolfgang.Etl.Abstractions.LoaderBase.LoadAsync(System.Collections.Generic.IAsyncEnumerable! items, System.Threading.CancellationToken token) -> System.Threading.Tasks.Task! virtual Wolfgang.Etl.Abstractions.TransformerBase.CreateProgressTimer(System.IProgress! progress) -> Wolfgang.Etl.Abstractions.IProgressTimer! +virtual Wolfgang.Etl.Abstractions.TransformerBase.Dispose(bool disposing) -> void +virtual Wolfgang.Etl.Abstractions.TransformerBase.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual Wolfgang.Etl.Abstractions.TransformerBase.TransformAsync(System.Collections.Generic.IAsyncEnumerable! items) -> System.Collections.Generic.IAsyncEnumerable! virtual Wolfgang.Etl.Abstractions.TransformerBase.TransformAsync(System.Collections.Generic.IAsyncEnumerable! items, System.IProgress! progress) -> System.Collections.Generic.IAsyncEnumerable! virtual Wolfgang.Etl.Abstractions.TransformerBase.TransformAsync(System.Collections.Generic.IAsyncEnumerable! items, System.IProgress! progress, System.Threading.CancellationToken token) -> System.Collections.Generic.IAsyncEnumerable! diff --git a/src/Wolfgang.Etl.Abstractions/TransformerBase.cs b/src/Wolfgang.Etl.Abstractions/TransformerBase.cs index 64b979c..d7f00f8 100644 --- a/src/Wolfgang.Etl.Abstractions/TransformerBase.cs +++ b/src/Wolfgang.Etl.Abstractions/TransformerBase.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; @@ -18,7 +19,9 @@ namespace Wolfgang.Etl.Abstractions; /// The type of the destination object /// The type of the progress object public abstract class TransformerBase - : ITransformWithProgressAndCancellationAsync + : ITransformWithProgressAndCancellationAsync, + IAsyncDisposable, + IDisposable where TSource : notnull where TDestination : notnull where TProgress : notnull @@ -27,6 +30,7 @@ public abstract class TransformerBase private int _currentSkippedItemCount; private long _startTimestamp; private DateTimeOffset _startedAtUtc; + private bool _disposed; @@ -425,4 +429,52 @@ private void EnsureStarted() _startedAtUtc = now; } } + + + + /// + /// Asynchronously releases the resources held by this transformer. The base implementation is a + /// no-op (the base owns no unmanaged resources); derived classes that hold resources override + /// to release them. Enables await using on any transformer. + /// + /// A completed for the default no-op implementation. + public virtual ValueTask DisposeAsync() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + return default; + } + + + + /// + /// Releases the resources held by this transformer. The base implementation is a no-op; derived + /// classes that hold resources override . + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + + + /// + /// Releases resources held by this transformer. Override in a derived class to dispose resources + /// it owns, then call base.Dispose(disposing). The base implementation only marks the + /// instance disposed and is idempotent. + /// + /// + /// when called from or + /// (dispose managed resources); when called from a finalizer. + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + } } diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs new file mode 100644 index 0000000..70e953d --- /dev/null +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Wolfgang.Etl.Abstractions.Tests.Unit.Models; + +namespace Wolfgang.Etl.Abstractions.Tests.Unit.BaseClassTests; + +/// +/// Verifies the / support added to the +/// base classes (issue #92): the default base implementation is a safe no-op, and a derived +/// override of Dispose(bool) is invoked through both Dispose() and DisposeAsync(). +/// +public class AsyncDisposableTests +{ + private sealed class PlainExtractor : ExtractorBase + { + protected override async IAsyncEnumerable ExtractWorkerAsync([EnumeratorCancellation] CancellationToken token) + { + await Task.CompletedTask; + yield break; + } + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + } + + + private sealed class ResourceOwningExtractor : ExtractorBase + { + public int DisposeCount { get; private set; } + + protected override async IAsyncEnumerable ExtractWorkerAsync([EnumeratorCancellation] CancellationToken token) + { + await Task.CompletedTask; + yield break; + } + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + } + } + + + [Fact] + public void Base_classes_implement_the_disposal_interfaces() + { + var sut = new PlainExtractor(); + + Assert.IsAssignableFrom(sut); + Assert.IsAssignableFrom(sut); + } + + + [Fact] + public async Task Default_DisposeAsync_is_a_safe_no_op() + { + var sut = new PlainExtractor(); + + // Should not throw. + await sut.DisposeAsync(); + sut.Dispose(); + } + + + [Fact] + public void Dispose_invokes_the_derived_override() + { + var sut = new ResourceOwningExtractor(); + + sut.Dispose(); + + Assert.Equal(1, sut.DisposeCount); + } + + + [Fact] + public async Task DisposeAsync_invokes_the_derived_override() + { + var sut = new ResourceOwningExtractor(); + + await sut.DisposeAsync(); + + Assert.Equal(1, sut.DisposeCount); + } + + + [Fact] + public async Task Await_using_disposes_a_resource_owning_extractor() + { + var sut = new ResourceOwningExtractor(); + + await using (sut) + { + await foreach (var _ in sut.ExtractAsync()) + { + } + } + + Assert.Equal(1, sut.DisposeCount); + } +} From fd191d646009d131ec75e772fa5381076d2077ad Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:36:19 -0400 Subject: [PATCH 2/2] test: cover loader/transformer disposal (#92 coverage gate) The dispose pattern was added to all three base classes but only the extractor's dispose was tested, dropping LoaderBase (85.9%) and TransformerBase (86.4%) under the 90% per-class coverage gate. Adds ResourceOwningLoader / ResourceOwningTransformer doubles asserting Dispose()/DisposeAsync() invoke the override and the interfaces are implemented. Both classes now ~98% line coverage. 249 tests pass. Co-Authored-By: Claude Opus 4.8 --- .../BaseClassTests/AsyncDisposableTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs index 70e953d..5d2d9eb 100644 --- a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/AsyncDisposableTests.cs @@ -103,4 +103,104 @@ public async Task Await_using_disposes_a_resource_owning_extractor() Assert.Equal(1, sut.DisposeCount); } + + + private sealed class ResourceOwningLoader : LoaderBase + { + public int DisposeCount { get; private set; } + + protected override Task LoadWorkerAsync(IAsyncEnumerable items, CancellationToken token) => Task.CompletedTask; + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + } + } + + + private sealed class ResourceOwningTransformer : TransformerBase + { + public int DisposeCount { get; private set; } + + protected override async IAsyncEnumerable TransformWorkerAsync(IAsyncEnumerable items, [EnumeratorCancellation] CancellationToken token) + { + await Task.CompletedTask; + yield break; + } + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + } + } + + + [Fact] + public void LoaderBase_implements_the_disposal_interfaces() + { + var sut = new ResourceOwningLoader(); + + Assert.IsAssignableFrom(sut); + Assert.IsAssignableFrom(sut); + } + + + [Fact] + public void LoaderBase_Dispose_invokes_the_derived_override() + { + var sut = new ResourceOwningLoader(); + + sut.Dispose(); + + Assert.Equal(1, sut.DisposeCount); + } + + + [Fact] + public async Task LoaderBase_DisposeAsync_invokes_the_derived_override() + { + var sut = new ResourceOwningLoader(); + + await sut.DisposeAsync(); + + Assert.Equal(1, sut.DisposeCount); + } + + + [Fact] + public void TransformerBase_implements_the_disposal_interfaces() + { + var sut = new ResourceOwningTransformer(); + + Assert.IsAssignableFrom(sut); + Assert.IsAssignableFrom(sut); + } + + + [Fact] + public void TransformerBase_Dispose_invokes_the_derived_override() + { + var sut = new ResourceOwningTransformer(); + + sut.Dispose(); + + Assert.Equal(1, sut.DisposeCount); + } + + + [Fact] + public async Task TransformerBase_DisposeAsync_invokes_the_derived_override() + { + var sut = new ResourceOwningTransformer(); + + await sut.DisposeAsync(); + + Assert.Equal(1, sut.DisposeCount); + } }