Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Transport/Import/IBundleUnlockAttemptWindow.cs
T
Joseph Doherty 2807b5a3ea fix(transport)+test: close deferred-work register rows 58, 60, 65 (Low-tail sweep)
Row 65 — trigger FIRED, fixed. BundleUnlockAttempt rows were pruned only by
nodes actively serving unlocks, so a cluster that stopped importing entirely
left its last rows in place forever. The row's own trigger was "Transport gains
a central scheduler a prune tick could ride" — it has one now:
BundleSessionEvictionService, the central-only 1-minute session sweep added by
the transport-session fixes. It now also ticks the new
IBundleUnlockAttemptWindow.PruneAsync. Both drivers share the ONE 15-minute CAS
cadence claim, so ticking on a 1-minute sweep costs at most one bounded DELETE
per quarter hour and the two can never prune concurrently. PruneAsync is
contractually non-throwing (unlike TryRegisterAttemptAsync, which throws and
lets the limiter own the fail-open decision) — a background sweep has no caller
to report to and must survive a database blip. Both Transport docs updated;
three tests in FleetWideUnlockThrottleTests, incl. the retention sweep running
with no unlock traffic anywhere in the test, which is the whole point.

Row 58 — closed with a pin, and the OLD pin was found blind. The existing
Deployments_Dispose_UnsubscribesFromNotifier is behavioural: it is satisfied by
the _disposed guard alone, so deleting the `-=` from Dispose leaves it green
while the singleton notifier keeps a delegate pinning the circuit-scoped
component. Verified by mutation — with the `-=` removed the old test still
passed and only the two new pins failed. Added:
  * a structural retention pin asserting the notifier's invocation list actually
    shrinks to 0 on Dispose;
  * a source scan pinning the row's stated TRIGGER ("a second page subscribes,
    or any subscriber lands without a matching Dispose unsubscribe") — every
    CentralUI file that subscribes must also unsubscribe and be IDisposable,
    with a non-empty guard so the scan cannot pass vacuously.

Row 60 — closed with a pin, and the register's premise corrected.
GlobalArtifactSnapshot does NOT hold EF-materialised entities: it is projected
into pure Commons DTOs before the fan-out. The EF state that genuinely crosses
into the parallel send is the tracked Site entities the lambda closes over, and
that is safe only while lazy loading is off. Pinned in ConfigurationDatabase
.Tests two ways — no proxies extension on the built DbContextOptions, and no
EntityFrameworkCore.Proxies package reference anywhere in the repo (the
structurally stronger half: UseLazyLoadingProxies() is not a compilable call
without it). The real invariant is now spelled out at the fan-out itself.

Row 59 — no product change; added the missing tripwire remark stating the
constraint the safety argument rests on (serial by design, one scoped DbContext,
do not Task.WhenAll; if it gets slow the fix is a scope PER SITE, not a shared
scope under fan-out). Line numbers in the register have drifted to :528-545.

Rows 36, 37, 64: verification only, no change — see the sweep report.
2026-08-17 03:42:31 -04:00

48 lines
2.9 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Register row 52: a sliding window of bundle-unlock attempts that is shared across the
/// central pair, so <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c> is a FLEET cap
/// rather than a per-node, per-process one.
/// <para>
/// Implementations are expected to THROW on store failure. The fail-open decision belongs
/// to <see cref="BundleUnlockRateLimiter"/>, which owns the policy (log a Warning and
/// permit the attempt), not to the storage layer.
/// </para>
/// </summary>
public interface IBundleUnlockAttemptWindow
{
/// <summary>
/// Registers one unlock attempt against the shared window and reports whether it was
/// within budget. Implementations must NOT record an attempt they are rejecting —
/// a denial burns no budget, matching the in-memory limiter's contract (register
/// row 44), so a client that keeps hammering cannot extend its own lockout.
/// </summary>
/// <param name="clientKey">Throttle bucket — the caller's remote IP, or the shared unattributed bucket.</param>
/// <param name="maxAttemptsPerWindow">The trailing-window cap (<c>MaxUnlockAttemptsPerIpPerHour</c>).</param>
/// <param name="window">Trailing window the attempts are counted over (one hour in production).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns><c>true</c> when the attempt was recorded (within budget); <c>false</c> when the bucket is exhausted.</returns>
Task<bool> TryRegisterAttemptAsync(
string clientKey, int maxAttemptsPerWindow, TimeSpan window, CancellationToken ct = default);
/// <summary>
/// Register row 65: drives one retention sweep of aged-out attempt rows independently
/// of the unlock path. The prune used to ride <see cref="TryRegisterAttemptAsync"/>
/// only, so a cluster that stopped importing entirely left its last rows in place
/// indefinitely; <c>BundleSessionEvictionService</c> — Transport's periodic
/// central-only sweep — now also ticks this, which closes that.
/// <para>
/// Unlike <see cref="TryRegisterAttemptAsync"/>, implementations must NOT throw on
/// store failure: this is pure retention maintenance with no caller to report to, and
/// the sweep that drives it must never be taken down by a database blip. Implementations
/// are also expected to keep their own cadence claim, so calling this more often than
/// the intended prune interval is harmless.
/// </para>
/// </summary>
/// <param name="window">Trailing window the attempts are counted over (one hour in production); rows are retained for a multiple of it.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when the prune attempt has finished (or been skipped for cadence).</returns>
Task PruneAsync(TimeSpan window, CancellationToken ct = default);
}