mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
206388da99
Replace synchronous DeleteAllTenantDataAsync with a queued async cascade
that wipes all 24 tenant-scoped tables safely, archives custom rules,
and survives crashes at every step.
Phase 1 (TenantOffboardFunction, ~200ms sync):
- Write History / ByTenantPointer / Marker rows (OffboardingAudit table)
- Flip TenantConfiguration.Disabled=true (the active auth gate)
- Enqueue with 6 min visibility-delay (cache-drain barrier =
TenantConfigurationService.CacheDuration 5 min + 1 min safety)
- Return 202 with EarliestProcessingAt for the UI countdown
Phase 2 (TenantOffboardingWorker + Handler, async after drain):
- Enumerate sessions, per-session cascade-enqueue, persist
Expectations blob (separate offboarding-state container)
- Drain probe against per-session progress blobs; self re-enqueue
up to 60x at 2 min visibility-delay
- 2.D-archive: GatherRules / AnalyzeRules / ImeLogPatterns moved
to TenantOffboardingCustomsArchive table (3-iter cap + verify)
- 2.D-wipe: SafeWipe over all tenant tables (4 variants: exact-PK,
composite-PK range, discriminator+TenantId, property-only) with
fetch+verify+delete pattern + SafeWipeVerificationException
- 2.E: blob-prefix wipe deletion-manifests/{tenantId}/
- 2.D-pass-2: re-wipe AuditLogs (defense-in-depth)
- 2.G: side-effects FIRST (Marker, Pointer, blob, audit, OpsEvent)
then History=Completed LAST (DrainCompletedAt as resume anchor)
- 2.F-final: delete TenantConfiguration row LAST so self-service
re-onboarding (next /api/auth/me auto-creates default) works
Defense-in-depth:
- OffboardingMarkerCleanupFunction (2h timer): sweeps Completed
markers after 15 min grace, conditional TenantConfiguration sweep
(only when row is still the offboarding tombstone — preserves
user-driven self-service re-onboards)
- Worker poison path: durable Failed state MUST land before queue
moves; transient failure leaves message visible for retry
- SessionDeletionHandler reorder: Audit + SignalR notify BEFORE
CompletedAt write so the drain predicate is correct
Web UI:
- /settings danger zone: typed "OFFBOARD" confirmation dialog,
post-202 drain-barrier banner with minute-based countdown, hint
"you can sign out and close this tab — deletion runs in
background", silent auto-logout when countdown elapses
- /admin/customs-archive (GA only): list runs + inspect entries +
typed "DELETE" confirmation modal for run/entry deletion
- OpsAlertRulesSection: TenantOffboardingFailed event registered
- lib/api.ts: shared CustomsArchive* response types
Tests: 100+ new tests covering drain contract, handler ordering,
worker poison path, ETag-CAS, SafeWipe variants, OffboardingFilters
prefix-bleed safety, MarkerCleanup conditional sweep, Bootstrap
auth-disabled-tenant gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
4.0 KiB
C#
106 lines
4.0 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models.Offboarding;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests.Offboarding;
|
|
|
|
/// <summary>
|
|
/// In-memory <see cref="ITenantCustomsArchiveRepository"/> for PR3.B handler + endpoint tests.
|
|
/// Idempotent upsert keyed by (PK, RK); supports the four query shapes the admin API uses.
|
|
/// </summary>
|
|
internal sealed class FakeCustomsArchiveRepository : ITenantCustomsArchiveRepository
|
|
{
|
|
private readonly Dictionary<string, TenantOffboardingCustomsArchiveEntry> _store
|
|
= new(System.StringComparer.Ordinal);
|
|
|
|
public IReadOnlyDictionary<string, TenantOffboardingCustomsArchiveEntry> Store => _store;
|
|
|
|
private static string Key(string pk, string rk) => pk + "|" + rk;
|
|
|
|
public Task UpsertAsync(TenantOffboardingCustomsArchiveEntry entry, CancellationToken ct = default)
|
|
{
|
|
_store[Key(entry.PartitionKey, entry.RowKey)] = Clone(entry);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<int> CountByRunAndTableAsync(string normalizedTenantId, string historyRowKey, string originalTable, CancellationToken ct = default)
|
|
{
|
|
var pk = $"{normalizedTenantId}_{historyRowKey}";
|
|
var count = _store.Values.Count(e =>
|
|
e.PartitionKey == pk && e.OriginalTable == originalTable);
|
|
return Task.FromResult(count);
|
|
}
|
|
|
|
public async IAsyncEnumerable<TenantOffboardingCustomsArchiveEntry> QueryByRunAsync(
|
|
string normalizedTenantId, string historyRowKey,
|
|
[EnumeratorCancellation] CancellationToken ct = default)
|
|
{
|
|
var pk = $"{normalizedTenantId}_{historyRowKey}";
|
|
foreach (var entry in _store.Values.Where(e => e.PartitionKey == pk))
|
|
{
|
|
yield return Clone(entry);
|
|
}
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
public async IAsyncEnumerable<TenantOffboardingCustomsArchiveEntry> QueryByTenantAsync(
|
|
string normalizedTenantId, [EnumeratorCancellation] CancellationToken ct = default)
|
|
{
|
|
var prefix = normalizedTenantId + "_";
|
|
foreach (var entry in _store.Values.Where(e => e.PartitionKey.StartsWith(prefix)))
|
|
{
|
|
yield return Clone(entry);
|
|
}
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
public async IAsyncEnumerable<TenantOffboardingCustomsArchiveEntry> QueryAllAsync(
|
|
[EnumeratorCancellation] CancellationToken ct = default)
|
|
{
|
|
foreach (var entry in _store.Values)
|
|
{
|
|
yield return Clone(entry);
|
|
}
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
public Task<TenantOffboardingCustomsArchiveEntry?> TryGetEntryAsync(
|
|
string partitionKey, string rowKey, CancellationToken ct = default)
|
|
{
|
|
_store.TryGetValue(Key(partitionKey, rowKey), out var entry);
|
|
return Task.FromResult<TenantOffboardingCustomsArchiveEntry?>(entry == null ? null : Clone(entry));
|
|
}
|
|
|
|
public Task DeleteEntryAsync(string partitionKey, string rowKey, CancellationToken ct = default)
|
|
{
|
|
_store.Remove(Key(partitionKey, rowKey));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<int> DeleteRunAsync(string normalizedTenantId, string historyRowKey, CancellationToken ct = default)
|
|
{
|
|
var pk = $"{normalizedTenantId}_{historyRowKey}";
|
|
var toDelete = _store.Keys.Where(k => k.StartsWith(pk + "|")).ToList();
|
|
foreach (var k in toDelete) _store.Remove(k);
|
|
return Task.FromResult(toDelete.Count);
|
|
}
|
|
|
|
private static TenantOffboardingCustomsArchiveEntry Clone(TenantOffboardingCustomsArchiveEntry e) => new()
|
|
{
|
|
PartitionKey = e.PartitionKey,
|
|
RowKey = e.RowKey,
|
|
TenantId = e.TenantId,
|
|
OriginalTable = e.OriginalTable,
|
|
OriginalPartitionKey = e.OriginalPartitionKey,
|
|
OriginalRowKey = e.OriginalRowKey,
|
|
EntityJson = e.EntityJson,
|
|
HistoryRowKey = e.HistoryRowKey,
|
|
ArchivedAt = e.ArchivedAt,
|
|
ArchivedBy = e.ArchivedBy,
|
|
};
|
|
}
|