merge: external-system retry policy end-to-end (register row 27) — MaxRetries/RetryDelay now travel artifact→gRPC→site schema→S&F enqueue, CLI --max-retries/--retry-delay-seconds, ArtifactDiff compares them

This commit is contained in:
Joseph Doherty
2026-08-15 17:56:21 -04:00
28 changed files with 1036 additions and 453 deletions
@@ -997,6 +997,35 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
SmtpConfigurations: null,
Timestamp: DateTimeOffset.UtcNow);
/// <summary>
/// Deferred-work row 27: the per-system Store-and-Forward retry policy must survive the
/// site APPLY path, not just the storage round-trip. Before the fix
/// <c>ExternalSystemArtifact</c> carried no retry fields at all, so a centrally-configured
/// policy was dropped on the floor here and every cached call fell back to the engine's
/// 50 x 30 s default.
/// </summary>
[Fact]
public async Task ApplyingArtifacts_PersistsExternalSystemRetryPolicy()
{
var manager = CreateDeploymentManager();
manager.Tell(ArtifactsCommand("dep-es-retry", externalSystems:
[
new ExternalSystemArtifact("MES", "https://mes", "ApiKey", null, null, 0,
MaxRetries: 9, RetryDelay: TimeSpan.FromSeconds(45))
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
await using var connection = _storage.CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT max_retries, retry_delay_ms FROM external_systems WHERE name = 'MES'";
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal(9, reader.GetInt32(0));
Assert.Equal(45_000, reader.GetInt64(1));
}
[Fact]
public async Task ApplyingArtifacts_DeletesExternalSystemsAbsentFromTheSet()
{
@@ -86,6 +86,52 @@ public class SiteRepositoryTests : IDisposable
Assert.Equal(7, found!.TimeoutSeconds);
}
/// <summary>
/// Deferred-work row 27: the per-system Store-and-Forward retry policy
/// (<c>MaxRetries</c>/<c>RetryDelay</c>) must round-trip through the site SQLite store,
/// so <c>ExternalSystemClient.CachedCallAsync</c> can hand it to the engine instead of
/// silently falling back to <c>DefaultMaxRetries</c> (50) x <c>DefaultRetryInterval</c> (30 s).
/// Before the fix the site table had no such columns and this read back 0 / TimeSpan.Zero.
/// </summary>
[Fact]
public async Task SiteStorage_ExternalSystem_RetryPolicy_RoundTrips()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync(
"MesApi", "https://mes.example.com", "ApiKey", null, null,
timeoutSeconds: 0, maxRetries: 7, retryDelay: TimeSpan.FromSeconds(90));
var repo = new SiteExternalSystemRepository(storage);
var found = await repo.GetExternalSystemByNameAsync("MesApi");
Assert.NotNull(found);
Assert.Equal(7, found!.MaxRetries);
Assert.Equal(TimeSpan.FromSeconds(90), found.RetryDelay);
}
/// <summary>
/// The unset case stays unset: a system deployed without a retry policy must read back as
/// 0 / <see cref="TimeSpan.Zero"/>, which is the value the gateway translates to null so the
/// engine defaults apply. This is what makes the new columns behaviourally invisible until
/// an operator actually configures a policy.
/// </summary>
[Fact]
public async Task SiteStorage_ExternalSystem_RetryPolicy_DefaultsToUnset()
{
var storage = NewStorage();
await storage.InitializeAsync();
await storage.StoreExternalSystemAsync(
"PlainApi", "https://plain.example.com", "ApiKey", null, null);
var repo = new SiteExternalSystemRepository(storage);
var found = await repo.GetExternalSystemByNameAsync("PlainApi");
Assert.NotNull(found);
Assert.Equal(0, found!.MaxRetries);
Assert.Equal(TimeSpan.Zero, found.RetryDelay);
}
/// <summary>
/// SiteRuntime-007: the synthetic ID for an external system must be identical when
/// the storage service and repository are re-created (simulating a process restart).