feat(transport): fleet-wide bundle-unlock throttle window (register row 52)

The per-IP unlock throttle's counters were a process-local ConcurrentDictionary
on each central node, so the effective fleet cap was N x
MaxUnlockAttemptsPerIpPerHour per client (failing over to the other node handed
the client a fresh budget) and a node restart cleared the window outright.

The window now lives in the central MS SQL BundleUnlockAttempt table — the HA
store both central nodes already share, so no new infrastructure, no new port,
no consensus protocol:

- Commons: BundleUnlockAttempt entity + IBundleUnlockAttemptRepository.
- ConfigurationDatabase: entity config (one index,
  IX_BundleUnlockAttempt_Client_Attempted, serving both the windowed count and
  the prune), repository (AsNoTracking count, ExecuteDeleteAsync prune),
  DbSet, DI registration, migration AddBundleUnlockAttemptTable + the
  hand-written idempotent production script under docs/plans/sql/.
- Transport: IBundleUnlockAttemptWindow seam + SqlBundleUnlockAttemptWindow
  (singleton; resolves the scoped repository from its own scope per call, like
  the central purge singletons). BundleUnlockRateLimiter keeps the policy and
  gains TryRegisterAttemptAsync; BundleImporter.LoadAsync calls it.

Behaviour preserved from row 44: locked bundles spend no hourly budget (the
lockout check still runs first), throttle denials burn no per-bundle strike and
are NOT recorded so they cannot roll the window forward, unattributable clients
share the stricter <unattributed> bucket, and the budget counts attempts rather
than failures. Config keys and their semantics are unchanged, so the rig's 500
override stays valid.

Three deliberate trades, documented in both Transport docs:
- FAILS OPEN. A store error logs a Warning naming the client and cap, and
  permits the attempt. This is a best-effort rate cap over an already
  authenticated, Administrator-gated surface — the availability of legitimate
  imports outranks the cap, and a DB blip must not make the import wizard
  unusable. Falling back to the process-local window was considered and
  rejected: it would still block a legitimate operator's 11th import.
- Approximate. Count-then-insert is not atomic across nodes, so simultaneous
  attempts can overshoot by the number in flight — bounded, self-correcting
  within the window, and not worth provider-specific raw SQL.
- Pruned opportunistically on the unlock path (CAS-claimed at most every 15
  min per process, bounded to 1000 rows, retaining 2x the window, best-effort
  try/catch) rather than by a new background service — Transport owns no
  central scheduler. Same shape as DeploymentService's terminal-record sweep.

Tests: Transport.IntegrationTests 116 passed (+10 — two limiter instances over
one store enforcing a single combined window, restart durability, denials
burning no budget, sliding release, bucket isolation, store-failure fail-open,
caller-cancellation not swallowed, no-shared-window fallback, prune and its
negative control); ConfigurationDatabase 395 passed (+5).
This commit is contained in:
Joseph Doherty
2026-08-15 11:52:34 -04:00
parent 372ea4df1b
commit 677263af57
21 changed files with 3157 additions and 30 deletions
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="BundleUnlockAttempt"/> POCO to the central
/// <c>BundleUnlockAttempt</c> table — the fleet-wide sliding window behind the Transport
/// component's per-client bundle-unlock throttle (register row 52). Operational history,
/// NOT audit, so the table is non-partitioned, standard <c>[PRIMARY]</c> filegroup, no
/// DB-role restriction.
/// </summary>
/// <remarks>
/// One named index, <c>IX_BundleUnlockAttempt_Client_Attempted</c>, serves both access
/// paths: the window count seeks on <c>ClientKey</c> and ranges on
/// <c>AttemptedAtUtc</c>, and the opportunistic prune scans the same index by time. A
/// second time-only index was deliberately not added — the table is kept small by the
/// prune, so the leading-column seek is cheap enough for the prune to share.
/// </remarks>
public class BundleUnlockAttemptEntityTypeConfiguration : IEntityTypeConfiguration<BundleUnlockAttempt>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="BundleUnlockAttempt"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<BundleUnlockAttempt> builder)
{
builder.ToTable("BundleUnlockAttempt");
// Surrogate long identity assigned by the store.
builder.HasKey(a => a.Id);
// Bucket key: an IP literal or the "<unattributed>" sentinel. 64 chars covers an
// IPv6 address with a scope id; ASCII, so non-unicode (varchar) like every other
// catalog-bounded column in the schema.
builder.Property(a => a.ClientKey).HasMaxLength(64).IsUnicode(false).IsRequired();
builder.Property(a => a.AttemptedAtUtc).IsRequired();
// Window index — seeks the bucket, ranges over the trailing hour, and is
// reused by the prune's time scan. Name locked for migration discoverability.
builder.HasIndex(a => new { a.ClientKey, a.AttemptedAtUtc })
.HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted");
}
}
@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddBundleUnlockAttemptTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BundleUnlockAttempt",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ClientKey = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
AttemptedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BundleUnlockAttempt", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_BundleUnlockAttempt_Client_Attempted",
table: "BundleUnlockAttempt",
columns: new[] { "ClientKey", "AttemptedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BundleUnlockAttempt");
}
}
}
@@ -1716,6 +1716,31 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("TemplateScripts");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport.BundleUnlockAttempt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("AttemptedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("ClientKey")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.HasKey("Id");
b.HasIndex("ClientKey", "AttemptedAtUtc")
.HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted");
b.ToTable("BundleUnlockAttempt", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
{
b.Property<Guid>("EventId")
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IBundleUnlockAttemptRepository"/> over the central
/// <c>BundleUnlockAttempt</c> table (register row 52). Plain reads/writes against the
/// shared <see cref="ScadaBridgeDbContext"/>; the prune uses <c>ExecuteDeleteAsync</c> so
/// no rows are materialised into the change tracker.
/// </summary>
public class BundleUnlockAttemptRepository : IBundleUnlockAttemptRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="BundleUnlockAttemptRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
public BundleUnlockAttemptRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<int> CountAttemptsSinceAsync(
string clientKey, DateTime sinceUtc, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
return await _context.BundleUnlockAttempts
.AsNoTracking()
.Where(a => a.ClientKey == clientKey && a.AttemptedAtUtc >= sinceUtc)
.CountAsync(ct);
}
/// <inheritdoc />
public async Task RecordAttemptAsync(
string clientKey, DateTime attemptedAtUtc, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
_context.BundleUnlockAttempts.Add(new BundleUnlockAttempt
{
ClientKey = clientKey,
AttemptedAtUtc = attemptedAtUtc,
});
await _context.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task<int> PruneAttemptsOlderThanAsync(
DateTime beforeUtc, int maxRows, CancellationToken ct = default)
{
if (maxRows < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxRows), "Prune batch size must be at least 1.");
}
// Bounded by construction: OrderBy + Take caps the delete regardless of how much
// history accumulated while the prune was failing or the node was down, so an
// opportunistic sweep on a request path can never become an unbounded delete.
return await _context.BundleUnlockAttempts
.Where(a => a.AttemptedAtUtc < beforeUtc)
.OrderBy(a => a.AttemptedAtUtc)
.Take(maxRows)
.ExecuteDeleteAsync(ct);
}
}
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
@@ -152,6 +153,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
/// <summary>Gets the set of hourly KPI rollups (pre-aggregated long-range trend backbone).</summary>
public DbSet<KpiRollupHourly> KpiRollupHourly => Set<KpiRollupHourly>();
// Transport (bundle export/import)
/// <summary>Gets the set of recorded bundle-unlock attempts — the fleet-wide sliding window behind the per-client unlock throttle (register row 52).</summary>
public DbSet<BundleUnlockAttempt> BundleUnlockAttempts => Set<BundleUnlockAttempt>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
/// <summary>Gets the set of data protection keys.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
@@ -87,6 +87,10 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISecuredWriteRepository, SecuredWriteRepository>();
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
// Register row 52: the fleet-wide bundle-unlock throttle window. Central-only in
// practice (Transport is registered in the host's Central branch), but registered
// here with every other repository so the ConfigurationDatabase surface stays uniform.
services.AddScoped<IBundleUnlockAttemptRepository, BundleUnlockAttemptRepository>();
// Auth re-arch: inbound API keys are no longer persisted in SQL Server —
// the repository now exposes only API-method access, so a plain scoped
// registration suffices (no peppered-hasher accessor to wire).