mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(backend): cascade-delete codex follow-ups (F1 V2 guard + upsert-revival, F2 partial-restore key-level re-increment, F3 legacy delete sessions-first, F4 restore 400 EntityAlreadyExists)
F1 — V2 ingest writes were landing past the cascade lock. Wired SessionDeletionGuard.EnsureWritableAsync into IngestTelemetryFunction before PersistItemsAsync so Event / Signal / Transition writes are refused with 410 Gone when DeletionState is Preparing / Queued / Running / Poisoned. Switched UpdateSessionImeAgentVersionAsync from UpsertEntityAsync to UpdateEntityAsync(ETag.All, Merge) with 404 swallow so a tombstoned Sessions row can no longer be silently resurrected by an in-flight ingest. F2 — Partial-restore could leave SoftwareInventory at -N drift when the cascade poisoned mid-AGGREGATE-loop: per-key decrement was persisted into AggregateDecrementsApplied BEFORE the underlying decrement, but the gate used CompletedSteps.Contains(aggregate.Order) — which is only set AFTER the whole loop. Switched the gate to AggregateDecrementsApplied as the authoritative ledger and pass a filtered DeletionStep into RunInventoryReIncrementWithIdempotencyAsync so only the keys that were actually decremented are re-incremented. F3 — Legacy direct-delete path deleted Events / RuleResults / AppInstallSummaries before the ETag-CAS-guarded DeleteSessionAsync, leaving a TOCTOU window where a V2 producer could CAS-claim DeletionState=Preparing after the pre-read but before our side- table mutations — landing the producer's manifest snapshot on top of already-half-purged state. Reordered both admin (DeleteSessionFunction) and retention (SessionRetentionFanoutService) paths to Sessions-CAS first; side-tables only after the Sessions row is gone (no producer can claim a missing row). F4 — Restore per-row Add fallback only caught HTTP 409 for duplicate- row conflicts; Azurite and some legacy Azure deployments surface duplicate-row as 400 with ErrorCode=EntityAlreadyExists, breaking idempotent restore-retry after partial-failed restore. Centralised the check via IsAlreadyExistsStatus(rfe) helper accepting 409 OR 400 + ErrorCode=EntityAlreadyExists; non-duplicate 400s still propagate. Tests: - SessionRestoreServiceTests: 2 partial-restore tests refit to the new AggregateDecrementsApplied contract + new explicit Codex-scenario test (only-decremented-keys re-incremented, untouched key skipped). - SessionDeletionPr4cCodexFixesTests F4: harness setup adds full AggregateDecrementsApplied so the per-key idempotency assertion reflects the new gate. - SessionRetentionFanoutServiceTests: pre-read tests replaced by two tests covering "DeleteSessionAsync refuses → no side tables" and "DeleteSessionAsync succeeds → side tables run after". - TableStorageRestoreBatchTests: two new F4 tests — 400 EntityAlreadyExists treated as Skipped, 400 with non-duplicate ErrorCode still propagates. - UpdateSessionImeAgentVersionTests (NEW): asserts UpdateEntityAsync is used (not Upsert), 404 swallowed silently, other storage errors non-fatal. Suites: Backend xUnit 1510, Agent V1 530, Agent V2 1042 — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -226,10 +226,16 @@ public class SessionDeletionPr4cCodexFixesTests
|
||||
{
|
||||
var harness = new RestoreHarness();
|
||||
harness.SetPoisonedCascade();
|
||||
// Simulate a prior partial restore that already re-incremented Office + Acrobat but
|
||||
// crashed before the CAS Poisoned → None (the exact Codex F4 scenario).
|
||||
var aggregateOrder = harness.Manifest.Steps.First(s => s.Class == DeletionStepClass.Aggregate).Order;
|
||||
harness.Progress.CompletedSteps.Add(aggregateOrder);
|
||||
// Scenario: forward cascade decremented all 3 keys → AggregateDecrementsApplied carries
|
||||
// the full set (authoritative ledger). A prior partial-restore re-incremented Office +
|
||||
// Acrobat (RestoreReIncrementsApplied) but crashed before CAS Poisoned → None. On retry,
|
||||
// only Firefox should be re-incremented — the other two are already idempotent.
|
||||
harness.Progress.AggregateDecrementsApplied = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Microsoft:Office:16.0",
|
||||
"Adobe:Acrobat:23.1",
|
||||
"Mozilla:Firefox:120.0",
|
||||
};
|
||||
harness.Progress.RestoreReIncrementsApplied = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Microsoft:Office:16.0",
|
||||
|
||||
@@ -225,13 +225,14 @@ public class SessionRestoreServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_partial_skips_inventory_re_increment_when_decrement_never_ran()
|
||||
public async Task Restore_partial_skips_inventory_re_increment_when_no_decrements_applied()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetPoisonedCascade();
|
||||
// CompletedSteps does NOT include the AGGREGATE step's order (16).
|
||||
// No per-key decrements were ever persisted. Restore must not blindly re-increment.
|
||||
harness.Progress.AggregateDecrementsApplied = null;
|
||||
harness.Progress.CompletedSteps.Clear();
|
||||
harness.Progress.CompletedSteps.Add(1); // poisoned right after step 1
|
||||
harness.Progress.CompletedSteps.Add(1); // poisoned right after step 1, before AGGREGATE entry
|
||||
|
||||
await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com");
|
||||
|
||||
@@ -241,12 +242,20 @@ public class SessionRestoreServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_partial_re_increments_inventory_when_decrement_already_ran()
|
||||
public async Task Restore_partial_re_increments_inventory_for_keys_in_AggregateDecrementsApplied()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetPoisonedCascade();
|
||||
// CompletedSteps includes the AGGREGATE step's order (16).
|
||||
harness.Progress.CompletedSteps.UnionWith(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 });
|
||||
// The handler persists each key into AggregateDecrementsApplied BEFORE the underlying
|
||||
// decrement runs. Restore must re-increment exactly those keys — even when the AGGREGATE
|
||||
// step's Order is NOT yet in CompletedSteps (poison fell between two keys).
|
||||
harness.Progress.CompletedSteps.UnionWith(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 });
|
||||
harness.Progress.AggregateDecrementsApplied = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Microsoft:Office:16.0",
|
||||
"Adobe:Acrobat:23.1",
|
||||
"Mozilla:Firefox:120.0",
|
||||
};
|
||||
|
||||
await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com");
|
||||
|
||||
@@ -255,6 +264,35 @@ public class SessionRestoreServiceTests
|
||||
Times.Exactly(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_partial_re_increments_only_keys_that_were_actually_decremented()
|
||||
{
|
||||
// The Codex finding scenario: the cascade poisoned mid-AGGREGATE-loop. 2 of 3 keys had
|
||||
// been persisted into AggregateDecrementsApplied (and their counters decremented) before
|
||||
// the crash; the AGGREGATE step never reached CompletedSteps. Restore must re-increment
|
||||
// only the 2 keys that were touched — re-incrementing the 3rd would create +1 drift.
|
||||
var harness = new Harness();
|
||||
harness.SetPoisonedCascade();
|
||||
harness.Progress.AggregateDecrementsApplied = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Microsoft:Office:16.0",
|
||||
"Adobe:Acrobat:23.1",
|
||||
// "Mozilla:Firefox:120.0" was never reached
|
||||
};
|
||||
|
||||
await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com");
|
||||
|
||||
harness.Storage.Verify(s => s.RestoreSoftwareInventoryEntryByKeyAsync(
|
||||
TenantId, It.Is<DeletionDecrementKey>(k => k.Vendor == "Microsoft" && k.Name == "Office"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
harness.Storage.Verify(s => s.RestoreSoftwareInventoryEntryByKeyAsync(
|
||||
TenantId, It.Is<DeletionDecrementKey>(k => k.Vendor == "Adobe" && k.Name == "Acrobat"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
harness.Storage.Verify(s => s.RestoreSoftwareInventoryEntryByKeyAsync(
|
||||
TenantId, It.Is<DeletionDecrementKey>(k => k.Vendor == "Mozilla" && k.Name == "Firefox"),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionDeletionState.Running)]
|
||||
[InlineData(SessionDeletionState.Preparing)]
|
||||
|
||||
+21
-22
@@ -139,50 +139,49 @@ public class SessionRetentionFanoutServiceTests
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────── PR6 follow-up F1 ─
|
||||
// Codex-followup: the ordering changed from "side-tables first, Sessions-CAS last" to
|
||||
// "Sessions-CAS first, side-tables after". DeleteSessionAsync's internal ETag-CAS is now the
|
||||
// single arbiter against in-flight V2 cascades — if it returns false (locked / 412 / 404),
|
||||
// side-tables stay untouched. This closes the TOCTOU window the pre-read couldn't.
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_legacy_path_skips_locked_session_WITHOUT_touching_side_tables()
|
||||
public async Task RunAsync_legacy_path_skips_side_tables_when_DeleteSessionAsync_refuses_locked_row()
|
||||
{
|
||||
// PR6 follow-up F1: pre-read DeletionState before any side-table mutation. Without this
|
||||
// gate the legacy path would delete Events / RuleResults / AppSummaries and only discover
|
||||
// the V2 cascade lock at the tombstone CAS — leaving the V2 cascade's manifest claiming
|
||||
// rows that were silently removed in the meantime.
|
||||
// DeleteSessionAsync detects the V2 cascade lock internally and returns false. The fanout
|
||||
// must record SessionsSkipped + leave side-tables intact so the V2 worker owns cleanup.
|
||||
var harness = new Harness();
|
||||
harness.WithTenant(TenantA, retentionDays: 30, v2: false, sessions: new[] { Old("s1", 60) });
|
||||
// Override the default fresh-read: this session is locked by a V2 cascade.
|
||||
harness.SessionRepo.Setup(r => r.GetSessionAsync(TenantA, "s1"))
|
||||
.ReturnsAsync(new SessionSummary
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SessionId = "s1",
|
||||
DeletionState = SessionDeletionState.Queued,
|
||||
PendingDeletionManifestId = "EXISTING-MANIFEST",
|
||||
});
|
||||
harness.SessionRepo.Setup(r => r.DeleteSessionAsync(TenantA, "s1")).ReturnsAsync(false);
|
||||
|
||||
var result = await harness.Sut.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.SessionsSkipped);
|
||||
Assert.Equal(0, result.SessionsLegacyDeleted);
|
||||
// Critical assertion: NONE of the side-table delete helpers were invoked.
|
||||
harness.SessionRepo.Verify(r => r.DeleteSessionAsync(TenantA, "s1"), Times.Once);
|
||||
// Side-tables MUST stay intact when DeleteSessionAsync refused.
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionAppInstallSummariesAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
harness.SessionRepo.Verify(r => r.DeleteSessionAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_legacy_path_skips_when_session_already_removed_during_preread()
|
||||
public async Task RunAsync_legacy_path_runs_side_tables_only_after_DeleteSessionAsync_succeeds()
|
||||
{
|
||||
// Edge case: row disappeared between GetSessionsOlderThan and the pre-read. Treat as skip
|
||||
// (no side-table work needed); no error.
|
||||
// Sessions-first ordering: side-table helpers fire ONLY after DeleteSessionAsync returns
|
||||
// true. No producer can claim a missing Sessions row, so side-table cleanup is race-free.
|
||||
var harness = new Harness();
|
||||
harness.WithTenant(TenantA, retentionDays: 30, v2: false, sessions: new[] { Old("s1", 60) });
|
||||
harness.SessionRepo.Setup(r => r.GetSessionAsync(TenantA, "s1")).ReturnsAsync((SessionSummary?)null);
|
||||
harness.SessionRepo.Setup(r => r.DeleteSessionAsync(TenantA, "s1")).ReturnsAsync(true);
|
||||
harness.MaintenanceRepo.Setup(m => m.DeleteSessionEventsAsync(TenantA, "s1")).ReturnsAsync(7);
|
||||
|
||||
var result = await harness.Sut.RunAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.SessionsSkipped);
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Equal(1, result.SessionsLegacyDeleted);
|
||||
Assert.Equal(0, result.SessionsSkipped);
|
||||
harness.SessionRepo.Verify(r => r.DeleteSessionAsync(TenantA, "s1"), Times.Once);
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionEventsAsync(TenantA, "s1"), Times.Once);
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionRuleResultsAsync(TenantA, "s1"), Times.Once);
|
||||
harness.MaintenanceRepo.Verify(m => m.DeleteSessionAppInstallSummariesAsync(TenantA, "s1"), Times.Once);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────── PR6 follow-up F2 ─
|
||||
|
||||
@@ -130,6 +130,47 @@ public class TableStorageRestoreBatchTests
|
||||
TableName, new[] { MakeDump("pkA", "rk_0") }, RestoreMode.Full));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_perRow_treats_400_EntityAlreadyExists_as_Skipped()
|
||||
{
|
||||
// Codex-followup F4: Azurite and some legacy Azure deployments map duplicate-row conflict
|
||||
// to HTTP 400 (ErrorCode=EntityAlreadyExists) instead of 409. The batch catch already
|
||||
// tolerated 400, but the per-row fallback's `status == 409`-only filter rethrew the 400,
|
||||
// breaking idempotent restore-retry after partial-failed restore.
|
||||
var harness = new Harness
|
||||
{
|
||||
BatchBehavior = _ => throw new RequestFailedException(400, "EntityAlreadyExists", "EntityAlreadyExists", innerException: null),
|
||||
PerRowAddBehavior = _ => throw new RequestFailedException(400, "EntityAlreadyExists", "EntityAlreadyExists", innerException: null),
|
||||
};
|
||||
|
||||
var result = await harness.Sut.RestoreRowsByExactKeysInBatchesAsync(
|
||||
TableName, new[] { MakeDump("pkA", "rk_dup") }, RestoreMode.Partial);
|
||||
|
||||
Assert.Equal(1, result.Attempted);
|
||||
Assert.Equal(0, result.Restored);
|
||||
Assert.Equal(1, result.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_perRow_400_without_EntityAlreadyExists_propagates()
|
||||
{
|
||||
// Negative case: a 400 that is NOT a duplicate-row conflict (e.g. validation error from
|
||||
// an oversized property) must still propagate. The new IsAlreadyExistsStatus helper keys
|
||||
// off ErrorCode = "EntityAlreadyExists" to distinguish.
|
||||
var harness = new Harness
|
||||
{
|
||||
BatchBehavior = _ => throw new RequestFailedException(400, "PropertyValueTooLarge", "PropertyValueTooLarge", innerException: null),
|
||||
PerRowAddBehavior = _ => throw new RequestFailedException(400, "PropertyValueTooLarge", "PropertyValueTooLarge", innerException: null),
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RequestFailedException>(() =>
|
||||
harness.Sut.RestoreRowsByExactKeysInBatchesAsync(
|
||||
TableName, new[] { MakeDump("pkA", "rk_x") }, RestoreMode.Partial));
|
||||
|
||||
Assert.Equal(400, ex.Status);
|
||||
Assert.Equal("PropertyValueTooLarge", ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_throws_on_null_args()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Codex-followup F1 (sub-finding): <c>UpdateSessionImeAgentVersionAsync</c> previously used
|
||||
/// <c>UpsertEntityAsync</c>, which would silently recreate a partial Sessions row after the
|
||||
/// cascade-delete worker had tombstoned it — breaking the manifest-snapshot invariant. The fix
|
||||
/// switches to <c>UpdateEntityAsync(entity, ETag.All, Merge)</c>: 404 means "row already gone"
|
||||
/// and is the correct no-op outcome.
|
||||
/// </summary>
|
||||
public class UpdateSessionImeAgentVersionTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_uses_UpdateEntityAsync_not_UpsertEntityAsync()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4");
|
||||
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.Is<ITableEntity>(e => e.PartitionKey == TenantId && e.RowKey == SessionId),
|
||||
It.IsAny<ETag>(),
|
||||
TableUpdateMode.Merge,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
// The critical invariant: the old Upsert path is GONE — a tombstoned row must not be
|
||||
// resurrected by an in-flight ingest landing past the cascade lock.
|
||||
harness.Sessions.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_swallows_404_when_row_is_tombstoned()
|
||||
{
|
||||
// The exact tombstone-revival scenario: cascade worker removed the Sessions row, an
|
||||
// in-flight ingest then tries to stamp ImeAgentVersion. UpdateEntityAsync surfaces 404 →
|
||||
// helper must return silently, NOT recreate the row.
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
|
||||
|
||||
await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4");
|
||||
|
||||
// No Upsert anywhere — 404 is the silent success path.
|
||||
harness.Sessions.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_swallows_other_storage_exceptions_as_warning()
|
||||
{
|
||||
// Method contract: failures are non-fatal. A 503 / 500 from storage logs a warning but
|
||||
// does not throw — preserves the pre-fix "don't block ingest" behaviour.
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "ServiceUnavailable"));
|
||||
|
||||
await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4");
|
||||
// No exception thrown.
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Sessions = new Mock<TableClient>();
|
||||
var mockServiceClient = new Mock<TableServiceClient>();
|
||||
mockServiceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
|
||||
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.IO;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -52,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly BlockedDeviceService _blockedDeviceService;
|
||||
private readonly BlockedVersionService _blockedVersionService;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
|
||||
public IngestTelemetryFunction(
|
||||
ILogger<IngestTelemetryFunction> logger,
|
||||
@@ -67,7 +69,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
BlockedDeviceService blockedDeviceService,
|
||||
BlockedVersionService blockedVersionService)
|
||||
BlockedVersionService blockedVersionService,
|
||||
SessionDeletionGuard deletionGuard)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionRepo = sessionRepo;
|
||||
@@ -83,6 +86,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_blockedDeviceService = blockedDeviceService;
|
||||
_blockedVersionService = blockedVersionService;
|
||||
_deletionGuard = deletionGuard;
|
||||
}
|
||||
|
||||
[Function("IngestTelemetry")]
|
||||
@@ -190,6 +194,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
"All telemetry items in a batch must share the same PartitionKey"));
|
||||
}
|
||||
|
||||
// Cascade-delete guard: refuse the batch with 410 Gone when a V2 cascade owns the
|
||||
// Sessions row (states Preparing/Queued/Running/Poisoned). Without this check, the
|
||||
// hot-path writers below (StoreEventsBatchAsync, signal/transition StoreBatchAsync,
|
||||
// UpdateSessionImeAgentVersionAsync upsert, …) would land rows past the lock and
|
||||
// leave orphan data the manifest cannot describe. One read per batch; absent
|
||||
// Sessions row → silent pass (caller handles session-not-found in its own write).
|
||||
try
|
||||
{
|
||||
await _deletionGuard.EnsureWritableAsync(bodyTenantId, sessionId, "V2.IngestTelemetry");
|
||||
}
|
||||
catch (SessionDeletionLockedException locked)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"IngestTelemetry: refused batch — cascade in flight tenant={Tenant} session={Session} state={State} manifestId={ManifestId}",
|
||||
bodyTenantId, sessionId, locked.CurrentState, locked.ManifestId);
|
||||
return AsOutput(await WriteSessionLockedAsync(req, locked));
|
||||
}
|
||||
|
||||
// Partition + persist. Events are routed through EventIngestProcessor which runs the
|
||||
// full pipeline (rule engine / app-install aggregation / SignalR / webhooks / ...);
|
||||
// Signal + Transition go straight to their repositories.
|
||||
@@ -294,6 +316,26 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Responds 410 Gone when the V2 cascade-delete guard refuses the batch. Per plan §5 PR3
|
||||
/// wiring table, telemetry ingest of a locked session is a terminal condition for the
|
||||
/// agent: the session is being torn down server-side and any further writes would create
|
||||
/// orphan rows. 410 is the documented status; the body shape mirrors the device-blocked
|
||||
/// response so existing agent code paths (which already short-circuit on Success=false)
|
||||
/// drop the batch without retry.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.Gone);
|
||||
await response.WriteAsJsonAsync(new IngestEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Session is being deleted by an administrator (state={locked.CurrentState}); further telemetry will be rejected.",
|
||||
ProcessedAt = DateTime.UtcNow,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partitions the incoming batch by <see cref="TelemetryItemDto.Kind"/> and persists each
|
||||
/// kind through its destination path. Events go through <see cref="EventIngestProcessor"/>
|
||||
|
||||
+74
-63
@@ -245,12 +245,68 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Legacy direct-delete path — preserved unchanged for the flag-OFF default. Removed by
|
||||
/// Legacy direct-delete path — preserved for the flag-OFF default. Removed by
|
||||
/// plan §5 PR7 once V2 has been stable on all tenants for at least two weeks.
|
||||
/// <para>
|
||||
/// <b>Ordering</b> (Sessions-first): the ETag-CAS-guarded <c>DeleteSessionAsync</c> runs
|
||||
/// <i>before</i> any side-table delete. This closes the TOCTOU window where a V2
|
||||
/// cascade producer could CAS-claim <c>DeletionState=Preparing</c> after our pre-read
|
||||
/// but before our side-table mutations — leaving the producer's manifest snapshot
|
||||
/// inconsistent against an already-half-purged side-table state. Side-table cleanup
|
||||
/// after the Sessions row is tombstoned is safe: no producer can claim a missing row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<HttpResponseData> RunLegacyDirectDeletePathAsync(
|
||||
HttpRequestData req, string tenantId, string sessionId, string userIdentifier)
|
||||
{
|
||||
// Sessions-first: tombstone (and release the writer-lock semantics) before any
|
||||
// side-table mutation. DeleteSessionAsync is ETag-CAS-guarded (PR5 finding 2)
|
||||
// against in-flight V2 cascades.
|
||||
var sessionDeleted = await _sessionRepo.DeleteSessionAsync(tenantId, sessionId);
|
||||
|
||||
if (!sessionDeleted)
|
||||
{
|
||||
// DeleteSessionAsync returned false. Either 404 (already gone) or a concurrent
|
||||
// V2 producer CAS-claimed the row (ETag 412 / non-None DeletionState). Re-read
|
||||
// to disambiguate and pick the right status code.
|
||||
var refreshed = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
|
||||
if (refreshed == null)
|
||||
{
|
||||
_logger.LogWarning($"Session {sessionId} not found at final delete (race with concurrent caller)");
|
||||
return await WriteResponse(req, HttpStatusCode.NotFound, new
|
||||
{
|
||||
success = false,
|
||||
message = $"Session {sessionId} not found",
|
||||
});
|
||||
}
|
||||
if (SessionDeletionState.IsLocked(refreshed.DeletionState))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Legacy delete refused: V2 cascade owns the session. " +
|
||||
"tenant={TenantId} session={SessionId} state={State}",
|
||||
tenantId, sessionId, refreshed.DeletionState);
|
||||
var poisonedHint = string.Equals(refreshed.DeletionState, SessionDeletionState.Poisoned, StringComparison.Ordinal);
|
||||
return await WriteResponse(req, HttpStatusCode.Conflict, new
|
||||
{
|
||||
success = false,
|
||||
message = poisonedHint
|
||||
? "Cascade is poisoned; recover via POST /api/admin/sessions/{id}/restore before retrying delete."
|
||||
: "A V2 cascade owns this session; retry later or use the cascade-delete path.",
|
||||
deletionState = refreshed.DeletionState,
|
||||
manifestId = refreshed.PendingDeletionManifestId,
|
||||
hint = poisonedHint ? "cascade_poisoned_use_restore" : "cascade_already_in_flight",
|
||||
});
|
||||
}
|
||||
_logger.LogError($"Legacy delete failed for session {sessionId} without an identifiable race cause");
|
||||
return await WriteResponse(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
success = false,
|
||||
message = "Internal server error",
|
||||
});
|
||||
}
|
||||
|
||||
// Sessions row is gone — no V2 producer can claim it now. Safe to cascade-clean
|
||||
// side tables without ordering races.
|
||||
var eventsDeleted = await _maintenanceRepo.DeleteSessionEventsAsync(tenantId, sessionId);
|
||||
_logger.LogInformation($"Deleted {eventsDeleted} events for session {sessionId}");
|
||||
|
||||
@@ -260,72 +316,27 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
var appSummariesDeleted = await _maintenanceRepo.DeleteSessionAppInstallSummariesAsync(tenantId, sessionId);
|
||||
_logger.LogInformation($"Deleted {appSummariesDeleted} app install summaries for session {sessionId}");
|
||||
|
||||
var sessionDeleted = await _sessionRepo.DeleteSessionAsync(tenantId, sessionId);
|
||||
|
||||
if (sessionDeleted)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
"DELETE",
|
||||
"Session",
|
||||
sessionId,
|
||||
userIdentifier,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "EventsDeleted", eventsDeleted.ToString() },
|
||||
{ "RuleResultsDeleted", ruleResultsDeleted.ToString() },
|
||||
{ "AppInstallSummariesDeleted", appSummariesDeleted.ToString() }
|
||||
});
|
||||
|
||||
_logger.LogInformation($"Successfully deleted session {sessionId}");
|
||||
return await WriteResponse(req, HttpStatusCode.OK, new
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
"DELETE",
|
||||
"Session",
|
||||
sessionId,
|
||||
userIdentifier,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
success = true,
|
||||
message = $"Session {sessionId} deleted successfully",
|
||||
eventsDeleted,
|
||||
ruleResultsDeleted,
|
||||
appInstallSummariesDeleted = appSummariesDeleted,
|
||||
{ "EventsDeleted", eventsDeleted.ToString() },
|
||||
{ "RuleResultsDeleted", ruleResultsDeleted.ToString() },
|
||||
{ "AppInstallSummariesDeleted", appSummariesDeleted.ToString() }
|
||||
});
|
||||
}
|
||||
|
||||
// DeleteSessionAsync returned false. PR5 finding 2: this is no longer just a 404
|
||||
// race — it can also mean a concurrent V2 producer CAS-claimed the row (ETag 412)
|
||||
// or the row's DeletionState is now non-None. Re-read to disambiguate and pick the
|
||||
// right status code so the UI shows the right message.
|
||||
var refreshed = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
|
||||
if (refreshed == null)
|
||||
_logger.LogInformation($"Successfully deleted session {sessionId}");
|
||||
return await WriteResponse(req, HttpStatusCode.OK, new
|
||||
{
|
||||
_logger.LogWarning($"Session {sessionId} not found at final delete (race with concurrent caller)");
|
||||
return await WriteResponse(req, HttpStatusCode.NotFound, new
|
||||
{
|
||||
success = false,
|
||||
message = $"Session {sessionId} not found",
|
||||
});
|
||||
}
|
||||
if (SessionDeletionState.IsLocked(refreshed.DeletionState))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Legacy delete refused: V2 cascade claimed the session between pre-read and tombstone. " +
|
||||
"tenant={TenantId} session={SessionId} state={State}",
|
||||
tenantId, sessionId, refreshed.DeletionState);
|
||||
var poisonedHint = string.Equals(refreshed.DeletionState, SessionDeletionState.Poisoned, StringComparison.Ordinal);
|
||||
return await WriteResponse(req, HttpStatusCode.Conflict, new
|
||||
{
|
||||
success = false,
|
||||
message = poisonedHint
|
||||
? "Cascade is poisoned; recover via POST /api/admin/sessions/{id}/restore before retrying delete."
|
||||
: "A V2 cascade claimed this session while the legacy delete was running; retry later.",
|
||||
deletionState = refreshed.DeletionState,
|
||||
manifestId = refreshed.PendingDeletionManifestId,
|
||||
hint = poisonedHint ? "cascade_poisoned_use_restore" : "cascade_already_in_flight",
|
||||
});
|
||||
}
|
||||
// Row still exists with DeletionState=None but the delete failed — generic error.
|
||||
_logger.LogError($"Legacy delete failed for session {sessionId} without an identifiable race cause");
|
||||
return await WriteResponse(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
success = false,
|
||||
message = "Internal server error",
|
||||
success = true,
|
||||
message = $"Session {sessionId} deleted successfully",
|
||||
eventsDeleted,
|
||||
ruleResultsDeleted,
|
||||
appInstallSummariesDeleted = appSummariesDeleted,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -346,11 +346,13 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
DeletionManifest manifest, DeletionProgress progress, string progressEtag,
|
||||
SessionRestoreResult result, CancellationToken ct)
|
||||
{
|
||||
// Determine whether the AGGREGATE decrement step had already run before the cascade
|
||||
// poisoned. If yes → re-increment to undo it. If no → leave counters alone (avoid
|
||||
// double +1).
|
||||
var aggregate = manifest.Steps.FirstOrDefault(s => s.Class == DeletionStepClass.Aggregate);
|
||||
var decrementAlreadyRan = aggregate != null && progress.CompletedSteps.Contains(aggregate.Order);
|
||||
// Re-increment policy in partial-restore: the AGGREGATE step's CompletedSteps marker
|
||||
// is set AFTER the per-key loop in SessionDeletionHandler. If the cascade poisoned
|
||||
// mid-loop, some keys are in AggregateDecrementsApplied (and were actually
|
||||
// decremented) but the step is NOT in CompletedSteps — using CompletedSteps as the
|
||||
// gate would skip the entire re-increment block and leave -N permanent drift.
|
||||
// Authoritative truth is AggregateDecrementsApplied (handler persists it BEFORE the
|
||||
// decrement call) — re-increment exactly those keys.
|
||||
|
||||
// Pass 1: every non-FINAL step in REVERSE cascade order.
|
||||
foreach (var step in manifest.Steps
|
||||
@@ -362,10 +364,31 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
switch (step.Class)
|
||||
{
|
||||
case DeletionStepClass.Aggregate:
|
||||
if (!decrementAlreadyRan) break;
|
||||
{
|
||||
var appliedKeys = progress.AggregateDecrementsApplied;
|
||||
if (step.Decrements == null || step.Decrements.Count == 0
|
||||
|| appliedKeys == null || appliedKeys.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var decrementsToReverse = step.Decrements
|
||||
.Where(k => appliedKeys.Contains(BuildAggregateCompositeKey(k)))
|
||||
.ToList();
|
||||
if (decrementsToReverse.Count == 0) break;
|
||||
|
||||
var partialStep = new DeletionStep
|
||||
{
|
||||
Order = step.Order,
|
||||
Table = step.Table,
|
||||
Step = step.Step,
|
||||
Class = step.Class,
|
||||
Decrements = decrementsToReverse,
|
||||
};
|
||||
(progress, progressEtag) = await RunInventoryReIncrementWithIdempotencyAsync(
|
||||
tenantId, sessionId, manifestId, step, progress, progressEtag, result, ct).ConfigureAwait(false);
|
||||
tenantId, sessionId, manifestId, partialStep, progress, progressEtag, result, ct).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case DeletionStepClass.PkBySession:
|
||||
case DeletionStepClass.PkRkExact:
|
||||
|
||||
+14
-26
@@ -307,41 +307,29 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
var tenantId = session.TenantId;
|
||||
var sessionId = session.SessionId;
|
||||
|
||||
// F1: gate side-table mutations on DeletionState. The CAS guard inside DeleteSessionAsync
|
||||
// is the storage-atomic backstop (PR5 finding 2), but it fires AFTER the side-table
|
||||
// deletes. By the time it returns false we've already orphaned events from the V2
|
||||
// cascade's planned manifest. Reading the Sessions row up-front is cheap (one Get) and
|
||||
// skips ~3 side-table deletes when the lock is held.
|
||||
var refreshed = await _sessionRepo.GetSessionAsync(tenantId, sessionId).ConfigureAwait(false);
|
||||
if (refreshed == null)
|
||||
{
|
||||
// Row already gone — nothing to clean up. Treat as skip (no events were ours to delete either).
|
||||
return false;
|
||||
}
|
||||
if (SessionDeletionState.IsLocked(refreshed.DeletionState))
|
||||
// Sessions-first: the ETag-CAS-guarded DeleteSessionAsync (PR5 finding 2) is the lock
|
||||
// arbiter against in-flight V2 cascades. Running it BEFORE side-table mutations means
|
||||
// a concurrent V2 producer either claims the row first (we get false → skip side
|
||||
// tables, V2 owns the cascade) or we win (Sessions gone → no producer can claim →
|
||||
// side-tables safe to cascade-clean). The previous side-tables-first ordering left
|
||||
// a TOCTOU window where V2's manifest snapshot landed on top of an already-half-
|
||||
// purged side-table state.
|
||||
var deleted = await _sessionRepo.DeleteSessionAsync(tenantId, sessionId).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Retention fanout (legacy): session {SessionId} is locked by a V2 cascade (state={State}, manifestId={ManifestId}) — skipping ALL deletes, V2 worker owns the cascade",
|
||||
sessionId, refreshed.DeletionState, refreshed.PendingDeletionManifestId);
|
||||
"Retention fanout (legacy): session {SessionId} not tombstoned — either already gone or owned by a V2 cascade. Side-tables left intact; V2 worker (or next retention run) will absorb",
|
||||
sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sessions row is gone — V2 producers cannot claim a missing row. Side-table cleanup
|
||||
// is now race-free.
|
||||
_ = await _maintenanceRepo.DeleteSessionEventsAsync(tenantId, sessionId).ConfigureAwait(false);
|
||||
_ = await _maintenanceRepo.DeleteSessionRuleResultsAsync(tenantId, sessionId).ConfigureAwait(false);
|
||||
_ = await _maintenanceRepo.DeleteSessionAppInstallSummariesAsync(tenantId, sessionId).ConfigureAwait(false);
|
||||
|
||||
// CAS guard inside DeleteSessionAsync (PR5 finding 2) refuses if DeletionState is locked
|
||||
// — backstop for the T1-T3 race after our pre-read above. A `false` here means either
|
||||
// 404 (already gone) is treated as success internally, OR a concurrent V2 cascade
|
||||
// claimed the row in the brief window between our pre-read and the tombstone.
|
||||
var deleted = await _sessionRepo.DeleteSessionAsync(tenantId, sessionId).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Retention fanout (legacy): session {SessionId} could not be tombstoned — likely claimed by a concurrent V2 cascade. Side tables already deleted; cascade is idempotent so V2 will absorb the difference. Will retry next run",
|
||||
sessionId);
|
||||
}
|
||||
return deleted;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await tableClient.SubmitTransactionAsync(actions, cancellationToken);
|
||||
restored += chunk.Count;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409 || ex.Status == 400)
|
||||
catch (RequestFailedException ex) when (IsAlreadyExistsStatus(ex))
|
||||
{
|
||||
// Azure rolls back the entire transaction when any Add hits an existing
|
||||
// row. Fall back to per-row AddEntity so we can distinguish "row already
|
||||
@@ -138,8 +138,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await tableClient.AddEntityAsync(ConvertDumpToEntity(dump, tableName), cancellationToken);
|
||||
restored++;
|
||||
}
|
||||
catch (RequestFailedException rfe) when (rfe.Status == 409)
|
||||
catch (RequestFailedException rfe) when (IsAlreadyExistsStatus(rfe))
|
||||
{
|
||||
// Mirror the batch-level relaxation: some Azure deployments and
|
||||
// the Azurite emulator surface duplicate-row conflict as HTTP 400
|
||||
// with ErrorCode=EntityAlreadyExists instead of 409. Counting only
|
||||
// 409 here left the per-row fallback non-idempotent on those
|
||||
// backends — restore-after-partial would throw on the leftover
|
||||
// rows that the previous attempt had already inserted.
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
@@ -242,5 +248,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Azure Tables batch transactions cap at 100 actions per submission, all sharing a
|
||||
// PartitionKey. Mirrors the const in the deletion partial.
|
||||
private const int RestoreBatchActionLimit = 100;
|
||||
|
||||
/// <summary>
|
||||
/// True when the Azure SDK exception encodes a "row already exists" outcome — the only
|
||||
/// case where we treat the failed Add as Skipped during restore. Production Azure Tables
|
||||
/// reports this as HTTP 409 Conflict (ErrorCode <c>EntityAlreadyExists</c>), but the
|
||||
/// Azurite emulator and some older service versions report HTTP 400 Bad Request with the
|
||||
/// same ErrorCode. Anything else propagates so we don't silently swallow genuine errors.
|
||||
/// </summary>
|
||||
private static bool IsAlreadyExistsStatus(RequestFailedException ex)
|
||||
{
|
||||
if (ex.Status == 409) return true;
|
||||
if (ex.Status == 400 && string.Equals(ex.ErrorCode, "EntityAlreadyExists", StringComparison.Ordinal))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2000,6 +2000,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Stores the IME agent version on an existing session (Merge-mode, single field update).
|
||||
/// Non-fatal: failures are logged as warnings and do not block ingest.
|
||||
/// <para>
|
||||
/// Uses <see cref="TableClient.UpdateEntityAsync(ITableEntity, ETag, TableUpdateMode, System.Threading.CancellationToken)"/>
|
||||
/// with <see cref="ETag.All"/>, not <c>UpsertEntityAsync</c>: a tombstoned Sessions row
|
||||
/// must stay tombstoned. The previous Upsert would silently recreate a partial Sessions
|
||||
/// row (PK/RK + ImeAgentVersion only) after the cascade-delete worker had removed it,
|
||||
/// breaking the manifest-snapshot invariant. 404 here means "row already gone" and is
|
||||
/// the correct no-op outcome.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
{
|
||||
@@ -2010,7 +2018,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
["ImeAgentVersion"] = version
|
||||
};
|
||||
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Merge);
|
||||
await tableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UpdateSessionImeAgentVersion no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)",
|
||||
tenantId, sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user