fix(transport): run bundle import inside the execution strategy (Gitea #28)

BundleImporter.ApplyAsync opened a user-initiated transaction directly,
but the central ConfigurationDb context is configured with
EnableRetryOnFailure. SqlServerRetryingExecutionStrategy rejects
user-initiated transactions, so every bundle import threw on any real
SQL Server ('...does not support user-initiated transactions.'). The
whole Transport suite ran on the in-memory provider (no retrying
strategy, BeginTransaction is a no-op) so it never surfaced.

Extract the transactional apply into ApplyMergeAsync and drive it via
_dbContext.Database.CreateExecutionStrategy().ExecuteAsync, so the
strategy owns the BeginTransaction -> apply -> Commit unit as one
retriable block. The delegate resets per-attempt state (change tracker
+ ApplyMergeAsync rebuilds its own summary/resolution accumulators) so a
retried attempt cannot double-apply; a rollback failure is captured and
surfaced on the BundleImportFailed audit row exactly as before. Post-
commit side effects (ScriptArtifactsChanged publish, session zero/
remove) moved outside the retriable delegate so they run once.

Regression test: BundleImporterRetryingStrategyTests runs the import on
SQLite with a retrying execution strategy (RetriesOnFailure == true,
arming the same guard as production). It fails with the exact production
error on the pre-fix code and passes after. Existing rollback/apply
contracts unchanged (104 integration + 154 unit tests green).
This commit is contained in:
Joseph Doherty
2026-07-23 13:55:09 -04:00
parent 9f91e84d83
commit c4dcd9bc02
2 changed files with 525 additions and 245 deletions
@@ -1272,6 +1272,182 @@ public sealed class BundleImporter : IBundleImporter
} }
var bundleImportId = Guid.NewGuid(); var bundleImportId = Guid.NewGuid();
// Set the correlation BEFORE the transaction so any audit writes
// triggered during the apply pick up the BundleImportId — AuditService
// reads the scoped context at the moment LogAsync is called.
_correlationContext.BundleImportId = bundleImportId;
// The central ConfigurationDb context is configured with
// EnableRetryOnFailure (ServiceCollectionExtensions), and
// SqlServerRetryingExecutionStrategy REJECTS a user-initiated
// transaction opened outside the strategy ("The configured execution
// strategy 'SqlServerRetryingExecutionStrategy' does not support
// user-initiated transactions."), so opening BeginTransactionAsync
// directly here throws on any real SQL Server. Run the whole
// BeginTransaction -> apply -> Commit unit INSIDE the execution strategy
// so the strategy owns it and re-executes it as one retriable unit on a
// transient fault. On the in-memory provider CreateExecutionStrategy()
// returns a non-retrying strategy, so this stays a single straight-
// through call there (behaviour unchanged).
//
// Retry idempotency: the strategy re-runs the WHOLE delegate on a
// transient fault, so the change tracker is cleared at the top of the
// delegate (adds staged by the failed attempt, whose transaction has
// already rolled back, must not leak into the retry) and ApplyMergeAsync
// rebuilds its own summary/resolution accumulators per call — otherwise
// a retry would double-apply. A rollback failure captured inside the
// delegate is surfaced to the outer catch via rollbackFailureMessage so
// the BundleImportFailed row still records both faults.
var strategy = _dbContext.Database.CreateExecutionStrategy();
string? rollbackFailureMessage = null;
ImportResult result;
try
{
result = await strategy.ExecuteAsync(async () =>
{
// Reset per-attempt state so a retried delegate re-runs clean.
_dbContext.ChangeTracker.Clear();
rollbackFailureMessage = null;
// BeginTransactionAsync is a no-op on the in-memory EF provider
// (which logs an InMemoryEventId.TransactionIgnoredWarning by
// default). To keep rollback semantics testable on in-memory AND
// correct on relational providers, ApplyMergeAsync defers the
// SINGLE final SaveChangesAsync until the very end — every
// Add*Async + LogAsync call only stages on the change tracker,
// so throwing before that naturally undoes the whole apply on
// both providers.
var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
// ApplyMergeAsync stages the whole merge and issues the
// single deferred final SaveChangesAsync itself; here we only
// commit the transaction it ran under.
var applied = await ApplyMergeAsync(
content, resolutions, nameMap, session, bundleImportId, user, ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
await tx.DisposeAsync().ConfigureAwait(false);
return applied;
}
catch
{
// Roll back explicitly (rather than leaning on Dispose) so a
// rollback that itself throws does not mask the ORIGINAL
// exception — capture it and let the original propagate to
// the strategy, which decides whether to retry.
try
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
}
catch (Exception rbEx)
{
rollbackFailureMessage = rbEx.Message;
}
try { await tx.DisposeAsync().ConfigureAwait(false); }
catch { /* dispose-after-throw must not mask the original cause */ }
// Clear the change tracker — on the in-memory provider the
// rollback is a no-op and the staged adds would otherwise
// persist on the next SaveChangesAsync (a retry or the
// failure-row write below).
_dbContext.ChangeTracker.Clear();
throw;
}
}).ConfigureAwait(false);
}
catch (Exception ex)
{
// The strategy exhausted its retries (or threw a non-transient
// error). The failed attempt already rolled back and cleared the
// change tracker inside the delegate, so all that remains here is
// the top-level BundleImportFailed audit row.
//
// Clear correlation FIRST so the failure row doesn't carry the now-
// rolled-back BundleImportId. The contract is: BundleImportFailed
// exists at top level (no correlation) so audit consumers can see
// imports that aborted before any rows landed.
_correlationContext.BundleImportId = null;
try
{
await _auditService.LogAsync(
user: user,
action: "BundleImportFailed",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
Reason = ex.Message,
ExceptionType = ex.GetType().FullName,
RollbackException = rollbackFailureMessage,
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch
{
// Audit-write is best-effort per design §10 ("Audit-write failure
// NEVER aborts the user-facing action — audit is best-effort, the
// action's own success/failure path is authoritative"). Swallow
// any failure here so the original exception below propagates
// unchanged rather than being masked by an audit-layer fault.
}
// T-007: a failed apply used to leave the BundleSession (with its
// decrypted secrets) in the in-memory store for the full 30-minute
// TTL — 10 failed 100 MB imports = 1 GB of plaintext still rooted.
// Drop the session here too so the secrets are released as soon as
// the failure surfaces, not when the next Get() happens to evict.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
throw;
}
finally
{
// Always clear — even on the success path the correlation only
// applies to the apply we just finished. Subsequent operations on
// this scope (e.g. a second concurrent apply on a circuit) must
// not inherit the import id.
_correlationContext.BundleImportId = null;
}
// Post-commit side effects — run ONCE, only after a durable commit, so a
// retried attempt neither publishes twice nor releases the session early.
PublishScriptArtifactChanges(resolutions);
// T-007: zero out the decrypted plaintext BEFORE remove so any
// caller-held reference (e.g. the Razor page that built the
// ImportPreview) sees the cleared buffer too. Remove drops the
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return result;
}
/// <summary>
/// Applies one bundle's merge inside the caller's transaction: semantic
/// validation, every Apply* pass, the name-keyed FK rewires, the stale-
/// instance computation and the BundleImported audit row, ending with the
/// deferred final SaveChangesAsync staged by the caller. Does NOT begin or
/// commit the transaction — <see cref="ApplyAsync"/> owns it so the whole
/// unit can run inside the context's execution strategy (EnableRetryOnFailure
/// rejects user-initiated transactions opened outside the strategy). Safe to
/// re-run: the caller clears the change tracker before each attempt, and
/// every accumulator here (summary counts, resolution map) is rebuilt per
/// call.
/// </summary>
private async Task<ImportResult> ApplyMergeAsync(
BundleContentDto content,
IReadOnlyList<ImportResolution> resolutions,
BundleNameMap nameMap,
BundleSession session,
Guid bundleImportId,
string user,
CancellationToken ct)
{
var resolutionMap = resolutions.ToDictionary( var resolutionMap = resolutions.ToDictionary(
r => (r.EntityType, r.Name), r => (r.EntityType, r.Name),
r => r); r => r);
@@ -1283,21 +1459,6 @@ public sealed class BundleImporter : IBundleImporter
// the operator to re-issue keys on this environment. // the operator to re-issue keys on this environment.
var apiKeysIgnored = content.ApiKeys?.Count ?? 0; var apiKeysIgnored = content.ApiKeys?.Count ?? 0;
// Set the correlation BEFORE the transaction so any audit writes
// triggered during the apply pick up the BundleImportId — AuditService
// reads the scoped context at the moment LogAsync is called.
_correlationContext.BundleImportId = bundleImportId;
// BeginTransactionAsync is a no-op on the in-memory EF provider (which
// logs an InMemoryEventId.TransactionIgnoredWarning by default). To keep
// rollback semantics testable on in-memory AND correct on relational
// providers, we defer the SINGLE SaveChangesAsync call until just before
// CommitAsync — every Add*Async + LogAsync call only stages on the
// change tracker, so throwing before SaveChangesAsync naturally undoes
// the entire apply on both providers.
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
// Run semantic validation FIRST — before any writes are staged. // Run semantic validation FIRST — before any writes are staged.
// This is purely a name-resolution scan over the in-memory DTOs + // This is purely a name-resolution scan over the in-memory DTOs +
// pre-existing target reads, so it has no ordering dependency on // pre-existing target reads, so it has no ordering dependency on
@@ -1469,17 +1630,6 @@ public sealed class BundleImporter : IBundleImporter
cancellationToken: ct).ConfigureAwait(false); cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
PublishScriptArtifactChanges(resolutions);
// T-007: zero out the decrypted plaintext BEFORE remove so any
// caller-held reference (e.g. the Razor page that built the
// ImportPreview) sees the cleared buffer too. Remove drops the
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return new ImportResult( return new ImportResult(
BundleImportId: bundleImportId, BundleImportId: bundleImportId,
@@ -1492,95 +1642,6 @@ public sealed class BundleImporter : IBundleImporter
ApiKeysIgnored: apiKeysIgnored, ApiKeysIgnored: apiKeysIgnored,
Warnings: validationWarnings); Warnings: validationWarnings);
} }
catch (Exception ex)
{
// Rollback can itself throw (connection drop mid-rollback, provider
// bug, etc). If it does, we must STILL write the BundleImportFailed
// audit row — otherwise a rollback-failure path silently swallows
// the import's audit trail. Capture the rollback exception (if any)
// and surface it on the failure row alongside the original cause.
Exception? rollbackFailure = null;
try
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
}
catch (Exception rbEx)
{
rollbackFailure = rbEx;
}
// If rollback threw the IDbContextTransaction is in an indeterminate
// state and still associated with the DbContext — a subsequent
// SaveChangesAsync would attempt to enlist in (or commit to) that
// broken transaction, and the failure-row would itself be rolled
// back when the transaction is finally disposed. Dispose it now so
// the audit-row write below uses a fresh implicit transaction. On
// the happy rollback path Dispose is a benign no-op (the using
// would call it on scope exit anyway).
if (rollbackFailure is not null)
{
try { await tx.DisposeAsync().ConfigureAwait(false); }
catch { /* dispose-after-throw must not mask the original cause */ }
}
// Clear the change tracker before writing the failure row — on the
// in-memory provider the rollback is a no-op and the staged adds
// would otherwise persist when the next SaveChangesAsync runs. This
// also matters when rollback threw: the change tracker is in an
// ambiguous state and we don't want the failure-write to sweep up
// any of the staged apply mutations.
_dbContext.ChangeTracker.Clear();
// Clear correlation FIRST so the failure row doesn't carry the now-
// rolled-back BundleImportId. The contract is: BundleImportFailed
// exists at top level (no correlation) so audit consumers can see
// imports that aborted before any rows landed.
_correlationContext.BundleImportId = null;
try
{
await _auditService.LogAsync(
user: user,
action: "BundleImportFailed",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
Reason = ex.Message,
ExceptionType = ex.GetType().FullName,
RollbackException = rollbackFailure?.Message,
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch
{
// Audit-write is best-effort per design §10 ("Audit-write failure
// NEVER aborts the user-facing action — audit is best-effort, the
// action's own success/failure path is authoritative"). Swallow
// any failure here so the original exception below propagates
// unchanged rather than being masked by an audit-layer fault.
}
// T-007: a failed apply used to leave the BundleSession (with its
// decrypted secrets) in the in-memory store for the full 30-minute
// TTL — 10 failed 100 MB imports = 1 GB of plaintext still rooted.
// Drop the session here too so the secrets are released as soon as
// the failure surfaces, not when the next Get() happens to evict.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
throw;
}
finally
{
// Always clear — even on the success path the correlation only
// applies to the apply we just finished. Subsequent operations on
// this scope (e.g. a second concurrent apply on a circuit) must
// not inherit the import id.
_correlationContext.BundleImportId = null;
}
}
/// <summary> /// <summary>
/// T-007: zeros the session's <see cref="BundleSession.DecryptedContent"/> /// T-007: zeros the session's <see cref="BundleSession.DecryptedContent"/>
@@ -0,0 +1,219 @@
using System.Data.Common;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Regression guard for Gitea #28: <see cref="BundleImporter.ApplyAsync"/> must
/// run its multi-<c>SaveChanges</c> apply through the context's execution
/// strategy, because the production central <c>ScadaBridgeDbContext</c> is
/// configured with <c>EnableRetryOnFailure</c> and
/// <c>SqlServerRetryingExecutionStrategy</c> REJECTS a user-initiated
/// transaction opened outside the strategy
/// (<c>CoreStrings.ExecutionStrategyExistingTransaction</c>) — so before the
/// fix every bundle import threw on any real SQL Server.
/// <para>
/// The rest of the Transport suite runs on the in-memory provider, which has NO
/// retrying strategy and where <c>BeginTransactionAsync</c> is a no-op, so it
/// could never surface this. Here we run on SQLite (real relational
/// transactions) AND configure a retrying execution strategy whose only job is
/// to have <c>RetriesOnFailure == true</c>, which arms the exact same guard as
/// the production SQL Server strategy. If <c>ApplyAsync</c> opened the
/// transaction directly, this test would throw before importing anything.
/// </para>
/// <para>
/// Reuses <see cref="SqliteCompatibleScadaBridgeDbContext"/> defined alongside
/// <see cref="BundleImporterRollbackFailureTests"/> in this assembly.
/// </para>
/// </summary>
public sealed class BundleImporterRetryingStrategyTests : IDisposable
{
private readonly ServiceProvider _provider;
private readonly DbConnection _sharedConnection;
public BundleImporterRetryingStrategyTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
// SQLite :memory: is per-connection, so pin one open connection for the
// whole fixture or the schema vanishes between DbContext instances.
_sharedConnection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
_sharedConnection.Open();
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddSingleton(sp =>
{
var builder = new DbContextOptionsBuilder<ScadaBridgeDbContext>();
// Configure a RETRYING execution strategy — this is the crux of the
// regression. RetriesOnFailure == true (MaxRetryCount > 0) makes EF
// reject any user-initiated BeginTransaction opened outside
// strategy.ExecuteAsync, exactly like the production SQL Server
// EnableRetryOnFailure configuration.
builder.UseSqlite(
_sharedConnection,
sqlite => sqlite.ExecutionStrategy(deps => new TestRetryingExecutionStrategy(deps)));
builder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
return builder.Options;
});
services.AddScoped<ScadaBridgeDbContext>(sp => new SqliteCompatibleScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
// IStaleInstanceProbe (DeploymentManager) + flatten/hash (TemplateEngine)
// so ApplyAsync's StaleInstanceIds computation resolves a real probe.
services.AddTemplateEngine();
services.AddDeploymentManager();
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_sharedConnection.Dispose();
}
[Fact]
public async Task ApplyAsync_succeeds_under_a_retrying_execution_strategy()
{
// Arrange: seed → export → wipe → apply(Add). Same shape as
// BundleImporterApplyTests.ApplyAsync_adds_new_artifacts_in_single_transaction,
// but the fixture's context carries a retrying execution strategy.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await WipeContentAsync();
// Act: before the fix this threw CoreStrings.ExecutionStrategyExistingTransaction
// ("... does not support user-initiated transactions.") from the direct
// BeginTransactionAsync. With the fix the apply runs inside the strategy.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var resolutions = new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Add, null),
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
};
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
// Assert: the whole transactional apply committed.
Assert.Equal(2, result.Added);
Assert.Equal(0, result.Overwritten);
Assert.NotEqual(Guid.Empty, result.BundleImportId);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump"));
Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn"));
// The BundleImported audit row landed inside the committed transaction.
Assert.Equal(1, await ctx.AuditLogEntries.CountAsync(a => a.Action == "BundleImported"));
}
}
// ---- helpers (mirror BundleImporterApplyTests) ----
private async Task<Guid> ExportAndLoadAsync()
{
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
using var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms);
ms.Position = 0;
await using var loadScope = _provider.CreateAsyncScope();
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
private async Task WipeContentAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.RemoveRange(ctx.Templates);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
await ctx.SaveChangesAsync();
}
/// <summary>
/// A minimal retrying execution strategy for the test. Its sole purpose is
/// to report <c>RetriesOnFailure == true</c> (inherited from
/// <see cref="ExecutionStrategy"/>, since <c>MaxRetryCount &gt; 0</c>), which
/// arms EF's user-initiated-transaction guard just like the production
/// <c>SqlServerRetryingExecutionStrategy</c>. It never actually retries —
/// <see cref="ShouldRetryOn"/> returns false — so a genuine fault surfaces
/// immediately instead of looping.
/// </summary>
private sealed class TestRetryingExecutionStrategy : ExecutionStrategy
{
public TestRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: base(dependencies, maxRetryCount: 3, maxRetryDelay: TimeSpan.FromMilliseconds(1))
{
}
protected override bool ShouldRetryOn(Exception exception) => false;
}
}