mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
5dbc44c4ec
PR4 ships the cascade-delete executor for the safe-cascade-delete plan: the
SessionDeletionWorker (BackgroundService queue poll-loop) plus the
SessionDeletionHandler (per-step execution, live verification, tombstone).
Worker is NOT registered in Program.cs — that lights up in PR5.
Three logical phases land together as one commit per Oliver's call:
PR4 — Worker + Handler + CascadeVerificationService
- SessionDeletionWorker: BatchSize=1, kill-switch entry guard, heartbeat task
extending visibility every 60s during long handler runs, poison after
max-dequeue
- SessionDeletionHandler: download manifest+progress, CAS Queued→Running
(with Running→Running resume), per-step dispatch by Class, AGGREGATE
decrement, live verification, ordered tombstone (SessionsIndex first,
Sessions last)
- CascadeVerificationService: per-Class live residual scans (CveIndex is
manifest-key-only per §12-Q8; AGGREGATE+FINAL skipped)
- NotifySessionDeletedAsync added to ISignalRNotificationService
- BlobStorageService: DownloadDeletionProgressAsync + ETag-CAS
UpdateDeletionProgressAsync
PR4b — Restore endpoint
- POST /api/admin/sessions/{sessionId}/restore (GA-only via Policy Catalog)
- SessionRestoreService: dispatch table per §13.4, full + partial-poisoned-
recovery modes
- TableStorageService.Restore.cs: RestoreRowsByExactKeysInBatchesAsync +
ConvertFromPropValue + ConvertDumpToEntity (Sessions-row override forces
DeletionState=None on restore so the row isn't permanently locked)
- RestoreSoftwareInventoryEntryByKeyAsync helper
- Worker MoveToPoisonAsync now CAS-transitions Running→Poisoned (E1 fix)
so restore can dispatch into partial-restore mode
PR4c — Codex review fixes (6 findings)
- F1: AGGREGATE per-key idempotency via DeletionProgress.AggregateDecrementsApplied
(persist-first/decrement-second bounds drift to +1 per crash vs unbounded -N)
- F2: TombstoneStarted flag closes the gap where sessions=null+completedAt=null
blocked restore after a tombstone crash
- F3a: Sessions row inserted LAST in full restore (was first → trapped retry
with RejectAlreadyAtOriginalState)
- F3b: RestoreMode.Full now 409-ignores (was throw-on-409); converges with
Partial mode, makes retry-after-partial-failure idempotent
- F4: Partial-restore per-key re-increment idempotency via
DeletionProgress.RestoreReIncrementsApplied
- F5: Poison transition pre-checks PendingDeletionManifestId so stale
envelopes can't flip a fresh active cascade to Poisoned
- F6: DownloadDeletionManifestWithShaAsync exposes snapshot SHA; Handler +
Restore both verify against progress.SnapshotSha256 (snapshot↔progress
binding per plan §3)
DeletionProgress schema gains 3 additive optional fields (AggregateDecrementsApplied,
RestoreReIncrementsApplied, TombstoneStarted) with safe defaults so PR1-PR4
progress blobs deserialize cleanly.
Tests: 1457/1457 backend xUnit facts green (1391 pre-PR4 + 66 new across PR4,
PR4b, PR4c). Full solution build green. Program.cs has zero
SessionDeletionWorker references (PR5 territory stays untouched).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
7.2 KiB
C#
178 lines
7.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.Models.Deletion;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Decode-direction tests for <see cref="TableStorageService.ConvertFromPropValue"/> +
|
|
/// <see cref="TableStorageService.ConvertDumpToEntity"/> (PR4b). Pairs with the existing
|
|
/// <c>DeletionManifestSerializationTests</c> which covers the encode direction. Together they
|
|
/// establish the byte-faithful EDM round-trip the manifest-as-backup guarantee depends on
|
|
/// (plan §13 / §3 "types preserved").
|
|
/// </summary>
|
|
public class TableStoragePropValueConversionTests
|
|
{
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_String()
|
|
{
|
|
var prop = MakeProp(DeletionPropEdmType.String, "\"hello world\"");
|
|
Assert.Equal("hello world", TableStorageService.ConvertFromPropValue(prop));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_Boolean_true_and_false()
|
|
{
|
|
Assert.Equal(true, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Boolean, "true")));
|
|
Assert.Equal(false, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Boolean, "false")));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_Int32()
|
|
{
|
|
Assert.Equal(42, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Int32, "42")));
|
|
Assert.Equal(-1, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Int32, "-1")));
|
|
Assert.Equal(int.MaxValue, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Int32, int.MaxValue.ToString(CultureInfo.InvariantCulture))));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_Int64()
|
|
{
|
|
Assert.Equal(123456789012L, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Int64, "123456789012")));
|
|
Assert.Equal(long.MaxValue, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Int64, long.MaxValue.ToString(CultureInfo.InvariantCulture))));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_Double()
|
|
{
|
|
Assert.Equal(3.14, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Double, "3.14")));
|
|
Assert.Equal(0.0, TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Double, "0")));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_DateTime_as_UTC()
|
|
{
|
|
// Encode side stamps with "o" round-trip format and forces UTC kind.
|
|
var json = JsonSerializer.Serialize(new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc).ToString("o", CultureInfo.InvariantCulture));
|
|
var result = TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.DateTime, json));
|
|
var dt = Assert.IsType<DateTime>(result);
|
|
Assert.Equal(new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), dt.ToUniversalTime());
|
|
Assert.Equal(DateTimeKind.Utc, dt.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_Guid_in_D_format()
|
|
{
|
|
var guid = new Guid("33333333-3333-3333-3333-333333333333");
|
|
var json = JsonSerializer.Serialize(guid.ToString("D"));
|
|
var result = TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Guid, json));
|
|
Assert.Equal(guid, Assert.IsType<Guid>(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_round_trips_Binary_as_Base64()
|
|
{
|
|
var bytes = new byte[] { 0x01, 0x02, 0x03, 0xff };
|
|
var json = JsonSerializer.Serialize(Convert.ToBase64String(bytes));
|
|
var result = TableStorageService.ConvertFromPropValue(MakeProp(DeletionPropEdmType.Binary, json));
|
|
var decoded = Assert.IsType<byte[]>(result);
|
|
Assert.Equal(bytes, decoded);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_returns_null_for_JsonNull_value()
|
|
{
|
|
// Encode path stamps EdmType=String + JsonElement(null) for .NET null values.
|
|
var prop = MakeProp(DeletionPropEdmType.String, "null");
|
|
Assert.Null(TableStorageService.ConvertFromPropValue(prop));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertFromPropValue_throws_on_unknown_EdmType()
|
|
{
|
|
var prop = new DeletionPropValue
|
|
{
|
|
EdmType = "ThisEdmTypeDoesNotExist",
|
|
Value = ParseJson("\"x\""),
|
|
};
|
|
Assert.Throws<System.IO.InvalidDataException>(() => TableStorageService.ConvertFromPropValue(prop));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertDumpToEntity_overrides_DeletionState_to_None_for_Sessions_row()
|
|
{
|
|
// Snapshot was captured after CAS-Preparing — without the override the restored row
|
|
// would carry DeletionState=Preparing + the old manifestId, locking the session forever.
|
|
var dump = new DeletionRowDump
|
|
{
|
|
Pk = "tenant-1",
|
|
Rk = "session-1",
|
|
Etag = "\"0xETAG\"",
|
|
Props = new Dictionary<string, DeletionPropValue>(StringComparer.Ordinal)
|
|
{
|
|
["DeletionState"] = MakeProp(DeletionPropEdmType.String, "\"Preparing\""),
|
|
["PendingDeletionManifestId"] = MakeProp(DeletionPropEdmType.String, "\"some-old-manifest\""),
|
|
["CurrentPhase"] = MakeProp(DeletionPropEdmType.String, "\"AccountSetup\""),
|
|
},
|
|
};
|
|
|
|
var entity = TableStorageService.ConvertDumpToEntity(dump, Constants.TableNames.Sessions);
|
|
|
|
Assert.Equal(SessionDeletionState.None, entity.GetString("DeletionState"));
|
|
Assert.Null(entity["PendingDeletionManifestId"]);
|
|
// Other props preserved.
|
|
Assert.Equal("AccountSetup", entity.GetString("CurrentPhase"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertDumpToEntity_does_NOT_override_DeletionState_for_other_tables()
|
|
{
|
|
// SessionsIndex (or any other table) shouldn't have DeletionState forced.
|
|
var dump = new DeletionRowDump
|
|
{
|
|
Pk = "tenant-1",
|
|
Rk = "rk-1",
|
|
Props = new Dictionary<string, DeletionPropValue>(StringComparer.Ordinal)
|
|
{
|
|
["SomeColumn"] = MakeProp(DeletionPropEdmType.String, "\"keep-me\""),
|
|
},
|
|
};
|
|
|
|
var entity = TableStorageService.ConvertDumpToEntity(dump, Constants.TableNames.SessionsIndex);
|
|
|
|
Assert.Equal("keep-me", entity.GetString("SomeColumn"));
|
|
Assert.False(entity.ContainsKey("DeletionState")); // Not added by override
|
|
}
|
|
|
|
[Fact]
|
|
public void ConvertDumpToEntity_preserves_pk_and_rk()
|
|
{
|
|
var dump = new DeletionRowDump
|
|
{
|
|
Pk = "tenant-foo",
|
|
Rk = "session-bar",
|
|
Props = new Dictionary<string, DeletionPropValue>(),
|
|
};
|
|
var entity = TableStorageService.ConvertDumpToEntity(dump, "AnyTable");
|
|
Assert.Equal("tenant-foo", entity.PartitionKey);
|
|
Assert.Equal("session-bar", entity.RowKey);
|
|
}
|
|
|
|
private static DeletionPropValue MakeProp(string edmType, string jsonValue) => new DeletionPropValue
|
|
{
|
|
EdmType = edmType,
|
|
Value = ParseJson(jsonValue),
|
|
};
|
|
|
|
private static JsonElement ParseJson(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
}
|