Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`Wolfgang.Etl.ErrorPolicies` package (new, lockstep-versioned with `Wolfgang.Etl.Abstractions`):**
a static `ItemErrorPolicy` factory of ready-made policies for a stage's `OnError` hook —
Comment thread
Chris-Wolfgang marked this conversation as resolved.
Outdated
`Skip`, `Abort`, `SkipAndLog(ILogger)`, and dead-letter families `SkipAndDeadLetter` /
`SkipDeadLetterAndLog`, each overloaded for a caller-owned `ICollection<ItemErrorContext>` or a
`System.Threading.Channels.ChannelWriter<ItemErrorContext>`. Ships from this repo so the shared
policy set is defined once for the whole ETL family; the core `Wolfgang.Etl.Abstractions` assembly
keeps its minimal dependency set — only this package takes `Microsoft.Extensions.Logging.Abstractions`
and `System.Threading.Channels`.

### Changed

### Deprecated
Expand Down
30 changes: 30 additions & 0 deletions ETL-Abstractions.sln
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolfgang.Etl.Abstractions.T
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolfgang.Etl.Abstractions.Tests.DocExamples", "tests\Wolfgang.Etl.Abstractions.Tests.DocExamples\Wolfgang.Etl.Abstractions.Tests.DocExamples.csproj", "{76B42021-1D8C-4608-B8D4-EEDC25081248}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolfgang.Etl.ErrorPolicies", "src\Wolfgang.Etl.ErrorPolicies\Wolfgang.Etl.ErrorPolicies.csproj", "{A59EF662-7051-4C8F-9515-640326502B15}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolfgang.Etl.ErrorPolicies.Tests.Unit", "tests\Wolfgang.Etl.ErrorPolicies.Tests.Unit\Wolfgang.Etl.ErrorPolicies.Tests.Unit.csproj", "{DD88B915-99AF-4545-AD35-6F296693B92B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -405,6 +409,30 @@ Global
{76B42021-1D8C-4608-B8D4-EEDC25081248}.Release|x64.Build.0 = Release|Any CPU
{76B42021-1D8C-4608-B8D4-EEDC25081248}.Release|x86.ActiveCfg = Release|Any CPU
{76B42021-1D8C-4608-B8D4-EEDC25081248}.Release|x86.Build.0 = Release|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Debug|x64.ActiveCfg = Debug|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Debug|x64.Build.0 = Debug|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Debug|x86.ActiveCfg = Debug|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Debug|x86.Build.0 = Debug|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Release|Any CPU.Build.0 = Release|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Release|x64.ActiveCfg = Release|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Release|x64.Build.0 = Release|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Release|x86.ActiveCfg = Release|Any CPU
{A59EF662-7051-4C8F-9515-640326502B15}.Release|x86.Build.0 = Release|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Debug|x64.ActiveCfg = Debug|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Debug|x64.Build.0 = Debug|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Debug|x86.ActiveCfg = Debug|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Debug|x86.Build.0 = Debug|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Release|Any CPU.Build.0 = Release|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Release|x64.ActiveCfg = Release|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Release|x64.Build.0 = Release|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Release|x86.ActiveCfg = Release|Any CPU
{DD88B915-99AF-4545-AD35-6F296693B92B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -439,6 +467,8 @@ Global
{B59B4926-9CDD-41C2-A367-42F030DFAF54} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
{8F775698-9B29-40AB-A02E-A72A9B073040} = {8220BC33-6632-4D4C-9A50-B7978141A4E3}
{76B42021-1D8C-4608-B8D4-EEDC25081248} = {8220BC33-6632-4D4C-9A50-B7978141A4E3}
{A59EF662-7051-4C8F-9515-640326502B15} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{DD88B915-99AF-4545-AD35-6F296693B92B} = {8220BC33-6632-4D4C-9A50-B7978141A4E3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F673635D-58CE-48A5-9AE4-31F4484BED9E}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFrameworks>net462;net472;net48;net481;netstandard2.0;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Version>0.20.0</Version>
<Version>0.21.0</Version>
<!-- Pin AssemblyVersion to a fixed binding-stability baseline so consumers
do not need to recompile / add binding redirects on every minor/patch
bump. Bump only on a deliberate breaking API change. FileVersion +
Expand Down
195 changes: 195 additions & 0 deletions src/Wolfgang.Etl.ErrorPolicies/ItemErrorPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Wolfgang.Etl.Abstractions;

namespace Wolfgang.Etl.ErrorPolicies;

/// <summary>
/// Ready-made policies for an ETL stage's <c>OnError</c> hook. Each is a
/// <see cref="Func{T, TResult}"/> from an <see cref="ItemErrorContext"/> to an
/// <see cref="ItemErrorAction"/>, so it can be assigned directly to a stage's <c>OnError</c>:
/// <example><code>
/// var deadLetters = new List&lt;ItemErrorContext&gt;();
/// var extractor = new SomeExtractor&lt;Record&gt;(source)
/// {
/// OnError = ItemErrorPolicy.SkipDeadLetterAndLog(deadLetters, logger)
/// };
/// </code></example>
/// The dead-letter overloads write to a caller-owned sink (a collection or a channel), so its size —
/// and therefore the memory a bad feed can consume — stays under the caller's control.
/// </summary>
Comment thread
Chris-Wolfgang marked this conversation as resolved.
public static class ItemErrorPolicy
{
/// <summary>
/// A policy that discards the failed item and continues with the next one. The stage increments
/// its error-item count (<c>CurrentErrorItemCount</c>) so the skip is never silent.
/// </summary>
public static Func<ItemErrorContext, ItemErrorAction> Skip { get; } = _ => ItemErrorAction.Skip;



/// <summary>
/// A policy that re-throws the failure and stops the run. Equivalent to leaving <c>OnError</c>
/// unset, provided for symmetry and explicitness.
/// </summary>
public static Func<ItemErrorContext, ItemErrorAction> Abort { get; } = _ => ItemErrorAction.Abort;



/// <summary>
/// Logs the failure as a warning through <paramref name="logger"/>, then discards the item and
/// continues.
/// </summary>
/// <param name="logger">The logger the returned policy writes each failure to.</param>
/// <returns>A policy that logs and returns <see cref="ItemErrorAction.Skip"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="logger"/> is <see langword="null"/>.</exception>
public static Func<ItemErrorContext, ItemErrorAction> SkipAndLog(ILogger logger)
{
if (logger is null)
{
throw new ArgumentNullException(nameof(logger));
}

return context =>
{
ItemErrorPolicyLog.ItemFailedAndSkipped(logger, context.ItemNumber, context.Exception);
return ItemErrorAction.Skip;
};
}



/// <summary>
/// Records the failure in <paramref name="deadLetters"/> (a "dead-letter" queue the caller owns),
/// then discards the item and continues.
/// </summary>
/// <param name="deadLetters">The caller-owned collection each failed item is added to.</param>
/// <returns>A policy that dead-letters and returns <see cref="ItemErrorAction.Skip"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="deadLetters"/> is <see langword="null"/>.</exception>
/// <remarks>
/// A single stage invokes this policy serially, so a plain <see cref="List{T}"/> is safe. If you
/// share one collection across stages running concurrently, either supply a thread-safe collection
/// or use the <see cref="SkipAndDeadLetter(ChannelWriter{ItemErrorContext})"/> overload — the policy
/// adds to the collection without locking.
/// </remarks>
public static Func<ItemErrorContext, ItemErrorAction> SkipAndDeadLetter(ICollection<ItemErrorContext> deadLetters)
{
if (deadLetters is null)
{
throw new ArgumentNullException(nameof(deadLetters));
}

return context =>
{
deadLetters.Add(context);
return ItemErrorAction.Skip;
};
}



/// <summary>
/// Writes the failure to <paramref name="deadLetters"/> (a caller-owned channel) with
/// <see cref="ChannelWriter{T}.TryWrite(T)"/>, then discards the item and continues. Because the
/// hook is synchronous the non-blocking <c>TryWrite</c> is used, so a bounded channel that is full
/// drops the failure — size the channel, or use <see cref="BoundedChannelFullMode"/>, accordingly.
/// </summary>
/// <param name="deadLetters">The caller-owned channel each failed item is written to.</param>
/// <returns>A policy that dead-letters and returns <see cref="ItemErrorAction.Skip"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="deadLetters"/> is <see langword="null"/>.</exception>
public static Func<ItemErrorContext, ItemErrorAction> SkipAndDeadLetter(ChannelWriter<ItemErrorContext> deadLetters)
{
if (deadLetters is null)
{
throw new ArgumentNullException(nameof(deadLetters));
}

return context =>
{
deadLetters.TryWrite(context);
return ItemErrorAction.Skip;
};
}



/// <summary>
/// Records the failure in <paramref name="deadLetters"/> and logs it as a warning through
/// <paramref name="logger"/>, then discards the item and continues.
/// </summary>
/// <param name="deadLetters">The caller-owned collection each failed item is added to.</param>
/// <param name="logger">The logger the returned policy writes each failure to.</param>
/// <returns>A policy that dead-letters, logs, and returns <see cref="ItemErrorAction.Skip"/>.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="deadLetters"/> or <paramref name="logger"/> is <see langword="null"/>.
/// </exception>
/// <remarks>
/// A single stage invokes this policy serially, so a plain <see cref="List{T}"/> is safe. If you
/// share one collection across stages running concurrently, either supply a thread-safe collection
/// or use the <see cref="SkipDeadLetterAndLog(ChannelWriter{ItemErrorContext}, ILogger)"/> overload —
/// the policy adds to the collection without locking.
/// </remarks>
public static Func<ItemErrorContext, ItemErrorAction> SkipDeadLetterAndLog
(
ICollection<ItemErrorContext> deadLetters,
ILogger logger
)
{
if (deadLetters is null)
{
throw new ArgumentNullException(nameof(deadLetters));
}

if (logger is null)
{
throw new ArgumentNullException(nameof(logger));
}

return context =>
{
deadLetters.Add(context);
ItemErrorPolicyLog.ItemFailedAndSkipped(logger, context.ItemNumber, context.Exception);
return ItemErrorAction.Skip;
};
}



/// <summary>
/// Writes the failure to <paramref name="deadLetters"/> with
/// <see cref="ChannelWriter{T}.TryWrite(T)"/> and logs it as a warning through
/// <paramref name="logger"/>, then discards the item and continues. See
/// <see cref="SkipAndDeadLetter(ChannelWriter{ItemErrorContext})"/> for the <c>TryWrite</c> caveat.
/// </summary>
/// <param name="deadLetters">The caller-owned channel each failed item is written to.</param>
/// <param name="logger">The logger the returned policy writes each failure to.</param>
/// <returns>A policy that dead-letters, logs, and returns <see cref="ItemErrorAction.Skip"/>.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="deadLetters"/> or <paramref name="logger"/> is <see langword="null"/>.
/// </exception>
public static Func<ItemErrorContext, ItemErrorAction> SkipDeadLetterAndLog
(
ChannelWriter<ItemErrorContext> deadLetters,
ILogger logger
)
{
if (deadLetters is null)
{
throw new ArgumentNullException(nameof(deadLetters));
}

if (logger is null)
{
throw new ArgumentNullException(nameof(logger));
}

return context =>
{
deadLetters.TryWrite(context);
ItemErrorPolicyLog.ItemFailedAndSkipped(logger, context.ItemNumber, context.Exception);
return ItemErrorAction.Skip;
};
}
}
19 changes: 19 additions & 0 deletions src/Wolfgang.Etl.ErrorPolicies/ItemErrorPolicyLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using Microsoft.Extensions.Logging;

namespace Wolfgang.Etl.ErrorPolicies;

/// <summary>
/// Cached <see cref="LoggerMessage"/> delegates for the logging error policies, so a per-item log
/// call allocates nothing on the hot path.
/// </summary>
internal static class ItemErrorPolicyLog
{
internal static readonly Action<ILogger, long, Exception?> ItemFailedAndSkipped =
LoggerMessage.Define<long>
(
LogLevel.Warning,
new EventId(1, nameof(ItemFailedAndSkipped)),
"Item {ItemNumber} failed to process and was skipped."
);
}
1 change: 1 addition & 0 deletions src/Wolfgang.Etl.ErrorPolicies/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#nullable enable
9 changes: 9 additions & 0 deletions src/Wolfgang.Etl.ErrorPolicies/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#nullable enable
Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.Abort.get -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.Skip.get -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.SkipAndDeadLetter(System.Collections.Generic.ICollection<Wolfgang.Etl.Abstractions.ItemErrorContext!>! deadLetters) -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.SkipAndDeadLetter(System.Threading.Channels.ChannelWriter<Wolfgang.Etl.Abstractions.ItemErrorContext!>! deadLetters) -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.SkipAndLog(Microsoft.Extensions.Logging.ILogger! logger) -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.SkipDeadLetterAndLog(System.Collections.Generic.ICollection<Wolfgang.Etl.Abstractions.ItemErrorContext!>! deadLetters, Microsoft.Extensions.Logging.ILogger! logger) -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
static Wolfgang.Etl.ErrorPolicies.ItemErrorPolicy.SkipDeadLetterAndLog(System.Threading.Channels.ChannelWriter<Wolfgang.Etl.Abstractions.ItemErrorContext!>! deadLetters, Microsoft.Extensions.Logging.ILogger! logger) -> System.Func<Wolfgang.Etl.Abstractions.ItemErrorContext!, Wolfgang.Etl.Abstractions.ItemErrorAction>!
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net472;net48;net481;netstandard2.0;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<!-- Lockstep with Wolfgang.Etl.Abstractions: this package is built and released from the same
repo under the same version and tag (like Wolfgang.Etl.TestKit + Wolfgang.Etl.TestKit.Xunit). -->
<Version>0.21.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>$([System.Text.RegularExpressions.Regex]::Replace("$(Version)", "[-+].*$", "")).0</FileVersion>
<!-- No EnablePackageValidation on the FIRST release: there is no previously-published baseline to
compare against. Enable it next cycle with PackageValidationBaselineVersion set to 0.21.0. -->
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
<Title>$(AssemblyName)</Title>
<Description>Ready-made item-error policies — skip, log, and dead-letter (to a collection or a channel) — for the OnError hook of Wolfgang.Etl extractors, loaders, and transformers. Built on Wolfgang.Etl.Abstractions.</Description>
Comment thread
Chris-Wolfgang marked this conversation as resolved.
Outdated
<PackageProjectUrl>https://github.com/Chris-Wolfgang/ETL-Abstractions</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/Chris-Wolfgang/ETL-Abstractions</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
<PackageIcon>ETL-Abstractions.png</PackageIcon>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<SignAssembly>False</SignAssembly>
<!-- The 10.0.10 logging packages drop net5.0/net7.0 from their tested TFM list; they resolve the
netstandard2.0 asset there and work. Silence the NETSDK TFM-support warnings on those TFMs. -->
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
<PackageTags>ETL;Extract-Transform-Load;error-handling;dead-letter</PackageTags>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Wolfgang.Etl.ErrorPolicies.Tests.Unit" />
</ItemGroup>

<ItemGroup>
<None Include="..\..\ETL-Abstractions.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Wolfgang.Etl.Abstractions\Wolfgang.Etl.Abstractions.csproj" />
</ItemGroup>

<!-- Logging.Abstractions is always a package reference (never in-box). -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
</ItemGroup>

<!-- System.Threading.Channels is in-box on net5.0+; only the netFx / netstandard2.0 TFMs need the package. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net462' OR '$(TargetFramework)' == 'net472' OR '$(TargetFramework)' == 'net48' OR '$(TargetFramework)' == 'net481' OR '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Threading.Channels" Version="10.0.10" />
</ItemGroup>

<!-- Analyzer PackageReferences are centralized in Directory.Build.props -->


</Project>
Loading
Loading