fix(secrets): hub push sweep isolates each row (scadaproj#45)
Secrets-008: SqlServerSecretSyncService.SweepAsync now catches a per-row push failure, logs it (EventId 4, name only) and continues, like the pull side. One row the hub refuses (a >450-char name, an over-long explicit KEK id) no longer stops every later name from reaching the hub. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+25
-3
@@ -108,9 +108,25 @@ public sealed partial class SqlServerSecretSyncService : BackgroundService
|
||||
|
||||
foreach (StoredSecret row in rows)
|
||||
{
|
||||
// Verbatim, like the replicator — the hub records the originating node's revision.
|
||||
await _hub.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
|
||||
pushed++;
|
||||
try
|
||||
{
|
||||
// Verbatim, like the replicator — the hub records the originating node's revision.
|
||||
await _hub.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
|
||||
pushed++;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Isolated per row, like the pull side (SecretReplicationReconciler.ApplyAsync).
|
||||
// The push set is recomputed in the same name order every interval, so one row the
|
||||
// hub refuses (a name longer than its NVARCHAR(450) column, an over-long explicit
|
||||
// KEK id) would otherwise stop every name sorting after it from ever reaching the
|
||||
// hub. Log it and keep going; it is retried every sweep.
|
||||
LogPushRowFailed(ex, row.Name.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,4 +171,10 @@ public sealed partial class SqlServerSecretSyncService : BackgroundService
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
|
||||
private partial void LogRowDiscarded(Exception exception, string secretName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 4,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "The hub refused secret row {SecretName} on push; the rest of the sweep continues and the row is retried next interval.")]
|
||||
private partial void LogPushRowFailed(Exception exception, string secretName);
|
||||
}
|
||||
|
||||
+9
-1
@@ -45,7 +45,15 @@ public sealed class SqliteBackedHub(SqliteSecretStore store) : ISecretReplicatio
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A row name the hub refuses on push, as SQL Server refuses a name longer than its
|
||||
/// <c>NVARCHAR(450)</c> column ("String or binary data would be truncated").
|
||||
/// </summary>
|
||||
public string? RejectPushOf { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
||||
store.ApplyReplicatedAsync(row, ct);
|
||||
row.Name.Value == RejectPushOf
|
||||
? throw new InvalidOperationException("String or binary data would be truncated.")
|
||||
: store.ApplyReplicatedAsync(row, ct);
|
||||
}
|
||||
|
||||
+18
@@ -88,6 +88,24 @@ public sealed class SqlServerSecretSyncServiceTests : IDisposable
|
||||
Assert.Equal(0xBB, hub!.Ciphertext[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_isolates_a_row_the_hub_rejects_so_later_names_still_push()
|
||||
{
|
||||
// Secrets-008: the push set is recomputed in the same name order every interval, so one
|
||||
// poison row used to stop every name sorting after it, forever.
|
||||
await _local.UpsertAsync(Row("a/first", 0x01), CancellationToken.None);
|
||||
await _local.UpsertAsync(Row("m/poison", 0x02), CancellationToken.None);
|
||||
await _local.UpsertAsync(Row("z/after", 0x03), CancellationToken.None);
|
||||
_hub.RejectPushOf = "m/poison";
|
||||
|
||||
(_, int pushed) = await CreateService().SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, pushed);
|
||||
Assert.NotNull(await _hubStore.GetAsync(new SecretName("a/first"), CancellationToken.None));
|
||||
Assert.NotNull(await _hubStore.GetAsync(new SecretName("z/after"), CancellationToken.None));
|
||||
Assert.Null(await _hubStore.GetAsync(new SecretName("m/poison"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_preserves_the_originating_revision_when_pushing()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user