mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
backend: byte-aware table transactions and classified storage failures on the ingest (audit F07)
Every entity-group transaction was chunked by count only; 100 payload-heavy rows (Signals with chunked PayloadJson, Events with 30k-char Message + DataJson) could exceed the 4 MiB transaction cap, and the failure surfaced as an opaque 500. The agent halves its batch only on 413 and treats 5xx as "replay later", so such a batch wedged the device forever. - TableTransactionBatcher: 100 actions, 3.5 MB per transaction, 1 MB per entity, never across partitions; an entity that cannot fit throws TableEntityTooLargeException. Used by the event, signal, transition, restore and rules writers; delete-only batches keep their count chunking. - StorageErrors: one classifier for RequestFailedException (transient 408/429/ 5xx, payload-too-large by status or error code, already-exists incl. the Azurite 400 shape); the two IsAlreadyExistsStatus copies use it. - Event writer: per-row fallback only for data-shaped rejections; throttling, outages and oversize batches propagate. Single and batched writer share one entity builder. - IngestTelemetryFunction: payload too large -> 413, transient -> 503 with Retry-After: 5, anything else 500. - TableStorageChunking.MaxPropertyChars is the one 30,000-char bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+54
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent's uploader halves its batch only on 413 and treats every 5xx as "replay later";
|
||||
/// <see cref="IngestTelemetryFunction.ClassifyStorageFailure"/> is what keeps an oversize
|
||||
/// transaction from surfacing as a 500 the device would retry forever.
|
||||
/// </summary>
|
||||
public class IngestStorageFailureClassificationTests
|
||||
{
|
||||
private static RequestFailedException Failure(int status, string? code = null)
|
||||
=> new RequestFailedException(status, "storage", code, null);
|
||||
|
||||
[Fact]
|
||||
public void Oversize_transaction_becomes_413_without_retry_after()
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(413, "RequestBodyTooLarge"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversize_reported_as_400_with_size_code_still_becomes_413()
|
||||
{
|
||||
var (status, _, _) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(400, "EntityTooLarge"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(503)]
|
||||
[InlineData(429)]
|
||||
[InlineData(500)]
|
||||
public void Transient_storage_failure_becomes_503_with_retry_after(int storageStatus)
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus));
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, status);
|
||||
Assert.Equal(IngestTelemetryFunction.StorageRetryAfterSeconds, retryAfter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(404)]
|
||||
[InlineData(409)]
|
||||
[InlineData(400)]
|
||||
public void Anything_else_stays_500(int storageStatus)
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus, "InvalidInput"));
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class StorageErrorsTests
|
||||
{
|
||||
private static RequestFailedException Failure(int status, string? code = null)
|
||||
=> new RequestFailedException(status, "storage said no", code, null);
|
||||
|
||||
[Theory]
|
||||
[InlineData(408)]
|
||||
[InlineData(429)]
|
||||
[InlineData(500)]
|
||||
[InlineData(502)]
|
||||
[InlineData(503)]
|
||||
[InlineData(504)]
|
||||
public void Transient_statuses_are_transient(int status)
|
||||
=> Assert.True(StorageErrors.IsTransient(Failure(status)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(400)]
|
||||
[InlineData(404)]
|
||||
[InlineData(409)]
|
||||
[InlineData(412)]
|
||||
[InlineData(413)]
|
||||
public void Client_side_statuses_are_not_transient(int status)
|
||||
=> Assert.False(StorageErrors.IsTransient(Failure(status)));
|
||||
|
||||
[Fact]
|
||||
public void Payload_too_large_by_status()
|
||||
=> Assert.True(StorageErrors.IsPayloadTooLarge(Failure(413)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("RequestBodyTooLarge")]
|
||||
[InlineData("EntityTooLarge")]
|
||||
[InlineData("PropertyValueTooLarge")]
|
||||
public void Payload_too_large_by_error_code_even_with_a_400(string code)
|
||||
=> Assert.True(StorageErrors.IsPayloadTooLarge(Failure(400, code)));
|
||||
|
||||
[Fact]
|
||||
public void A_plain_400_is_not_payload_too_large()
|
||||
=> Assert.False(StorageErrors.IsPayloadTooLarge(Failure(400, "InvalidInput")));
|
||||
|
||||
[Fact]
|
||||
public void Already_exists_is_409_or_the_azurite_400_shape()
|
||||
{
|
||||
Assert.True(StorageErrors.IsAlreadyExists(Failure(409)));
|
||||
Assert.True(StorageErrors.IsAlreadyExists(Failure(400, "EntityAlreadyExists")));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(Failure(400, "InvalidInput")));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(Failure(412)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_is_never_classified()
|
||||
{
|
||||
Assert.False(StorageErrors.IsTransient(null!));
|
||||
Assert.False(StorageErrors.IsPayloadTooLarge(null!));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TableStorageService.StoreEventsBatchAsync"/> after audit F07: byte-aware
|
||||
/// transactions, and a per-row fallback only for data-shaped rejections. Throttling, outages
|
||||
/// and oversize batches propagate so the ingest can answer 503 / 413 and the agent replays.
|
||||
/// </summary>
|
||||
public class TableStorageEventBatchTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static EnrollmentEvent Event(int i, int payloadChars = 0)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
if (payloadChars > 0) data["blob"] = new string('d', payloadChars);
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
EventId = $"e{i:D4}",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "test_event",
|
||||
Source = "test",
|
||||
Message = payloadChars > 0 ? new string('m', payloadChars) : $"message {i}",
|
||||
Timestamp = new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc).AddMilliseconds(i),
|
||||
ReceivedAt = DateTime.UtcNow,
|
||||
Sequence = i,
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transient_failure_propagates_without_per_row_fallback()
|
||||
{
|
||||
var h = new Harness { BatchBehavior = _ => throw new RequestFailedException(503, "busy", "ServerBusy", null) };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.StoreEventsBatchAsync(Enumerable.Range(0, 5).Select(i => Event(i)).ToList()));
|
||||
|
||||
Assert.Single(h.SubmittedBatches);
|
||||
Assert.Empty(h.PerRowEventUpserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Oversize_batch_propagates_without_per_row_fallback()
|
||||
{
|
||||
var h = new Harness { BatchBehavior = _ => throw new RequestFailedException(413, "too big", "RequestBodyTooLarge", null) };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.StoreEventsBatchAsync(Enumerable.Range(0, 5).Select(i => Event(i)).ToList()));
|
||||
|
||||
Assert.Empty(h.PerRowEventUpserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Data_shaped_rejection_falls_back_to_per_row_writes()
|
||||
{
|
||||
var h = new Harness { BatchBehavior = _ => throw new RequestFailedException(400, "bad row", "InvalidInput", null) };
|
||||
var events = Enumerable.Range(0, 5).Select(i => Event(i)).ToList();
|
||||
|
||||
var stored = await h.Sut.StoreEventsBatchAsync(events);
|
||||
|
||||
Assert.Equal(5, stored.Count);
|
||||
Assert.Equal(5, h.PerRowEventUpserts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Payload_heavy_events_split_into_several_transactions_under_the_byte_budget()
|
||||
{
|
||||
// Each event carries ~30k chars in Message and ~30k in DataJson (truncation bound) ≈ 120 KB;
|
||||
// 40 of them exceed the 3.5 MB budget, so the writer must submit more than one transaction.
|
||||
var events = Enumerable.Range(0, 40).Select(i => Event(i, payloadChars: 40_000)).ToList();
|
||||
var h = new Harness();
|
||||
|
||||
var stored = await h.Sut.StoreEventsBatchAsync(events);
|
||||
|
||||
Assert.Equal(40, stored.Count);
|
||||
Assert.True(h.SubmittedBatches.Count >= 2, $"expected ≥2 transactions, got {h.SubmittedBatches.Count}");
|
||||
Assert.Equal(40, h.SubmittedBatches.Sum(b => b.Count));
|
||||
Assert.Empty(h.PerRowEventUpserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Small_batches_stay_one_transaction_and_dedup_last_wins()
|
||||
{
|
||||
var h = new Harness();
|
||||
var a = Event(1); a.Message = "first";
|
||||
var b = Event(1); b.Message = "second"; // same Timestamp + Sequence → same RowKey
|
||||
|
||||
var stored = await h.Sut.StoreEventsBatchAsync(new List<EnrollmentEvent> { a, b, Event(2) });
|
||||
|
||||
Assert.Equal(2, stored.Count);
|
||||
var batch = Assert.Single(h.SubmittedBatches);
|
||||
Assert.Equal(2, batch.Count);
|
||||
Assert.Equal("second", ((TableEntity)batch[0].Entity).GetString("Message"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public List<List<TableTransactionAction>> SubmittedBatches { get; } = new();
|
||||
public List<(string Pk, string Rk)> PerRowEventUpserts { get; } = new();
|
||||
public Func<List<TableTransactionAction>, Response<IReadOnlyList<Response>>>? BatchBehavior { get; set; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var table = new Mock<TableClient>();
|
||||
table
|
||||
.Setup(c => c.SubmitTransactionAsync(It.IsAny<IEnumerable<TableTransactionAction>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<TableTransactionAction>, CancellationToken>((actions, _) =>
|
||||
{
|
||||
var snapshot = actions.ToList();
|
||||
SubmittedBatches.Add(snapshot);
|
||||
if (BatchBehavior != null) return Task.FromResult(BatchBehavior(snapshot));
|
||||
var inner = (IReadOnlyList<Response>)snapshot.Select(_ => new Mock<Response>().Object).ToList();
|
||||
return Task.FromResult(Response.FromValue(inner, new Mock<Response>().Object));
|
||||
});
|
||||
table
|
||||
.Setup(c => c.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((entity, _, _) =>
|
||||
{
|
||||
// Event rows live in the composite {tenant}_{session} partition; the
|
||||
// EventSessionIndex side row uses the bare tenant id.
|
||||
if (entity.PartitionKey.Contains('_'))
|
||||
PerRowEventUpserts.Add((entity.PartitionKey, entity.RowKey));
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(It.IsAny<string>())).Returns(table.Object);
|
||||
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TableTransactionBatcher"/> is the one place that knows all three Azure Tables
|
||||
/// limits (100 actions, ~4 MiB per transaction, ~1 MiB per entity). These tests pin the split
|
||||
/// rules; the writers only iterate what it returns.
|
||||
/// </summary>
|
||||
public class TableTransactionBatcherTests
|
||||
{
|
||||
private static TableEntity Row(string pk, string rk, int payloadChars = 0)
|
||||
{
|
||||
var e = new TableEntity(pk, rk) { ["Kind"] = "x", ["Ordinal"] = 1L };
|
||||
if (payloadChars > 0) e["PayloadJson"] = new string('p', payloadChars);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_caps_at_100_actions_per_transaction_and_preserves_order()
|
||||
{
|
||||
var rows = Enumerable.Range(0, 250).Select(i => Row("pk", $"r{i:D4}")).ToList();
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.UpsertReplace);
|
||||
|
||||
Assert.Equal(new[] { 100, 100, 50 }, batches.Select(b => b.Count).ToArray());
|
||||
Assert.All(batches.SelectMany(b => b), a => Assert.Equal(TableTransactionActionType.UpsertReplace, a.ActionType));
|
||||
Assert.Equal(rows.Select(r => r.RowKey), batches.SelectMany(b => b).Select(a => a.Entity.RowKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_starts_a_new_transaction_when_the_byte_budget_would_overflow()
|
||||
{
|
||||
// 200k chars ≈ 400 KB each → 8 fit under 3.5 MB, the 9th does not.
|
||||
var rows = Enumerable.Range(0, 20).Select(i => Row("pk", $"r{i:D2}", payloadChars: 200_000)).ToList();
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.UpsertReplace);
|
||||
|
||||
Assert.Equal(new[] { 8, 8, 4 }, batches.Select(b => b.Count).ToArray());
|
||||
Assert.All(batches, b => Assert.True(
|
||||
b.Sum(a => TableTransactionBatcher.EstimateEntityBytes((TableEntity)a.Entity)) <= TableTransactionBatcher.TransactionByteBudget));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_never_mixes_partitions_in_one_transaction()
|
||||
{
|
||||
var rows = new[] { Row("a", "1"), Row("a", "2"), Row("b", "1"), Row("a", "3") };
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.Add);
|
||||
|
||||
Assert.Equal(3, batches.Count);
|
||||
Assert.All(batches, b => Assert.Single(b.Select(a => a.Entity.PartitionKey).Distinct()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_throws_for_an_entity_that_can_never_fit()
|
||||
{
|
||||
var rows = new[] { Row("pk", "ok"), Row("pk", "huge", payloadChars: 600_000) };
|
||||
|
||||
var ex = Assert.Throws<TableEntityTooLargeException>(
|
||||
() => TableTransactionBatcher.Split(rows, TableTransactionActionType.UpsertReplace));
|
||||
|
||||
Assert.Equal("pk", ex.PartitionKey);
|
||||
Assert.Equal("huge", ex.RowKey);
|
||||
Assert.True(ex.EstimatedBytes > TableTransactionBatcher.EntityByteLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_honours_a_smaller_action_cap()
|
||||
{
|
||||
var rows = Enumerable.Range(0, 7).Select(i => Row("pk", $"r{i}")).ToList();
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.Delete, maxActions: 3);
|
||||
|
||||
Assert.Equal(new[] { 3, 3, 1 }, batches.Select(b => b.Count).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_of_nothing_is_nothing()
|
||||
{
|
||||
Assert.Empty(TableTransactionBatcher.Split(Array.Empty<TableEntity>(), TableTransactionActionType.UpsertReplace));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Estimate_counts_strings_at_utf16_width_binaries_at_base64_growth_and_scalars_flat()
|
||||
{
|
||||
var e = new TableEntity("pk", "rk")
|
||||
{
|
||||
["S"] = new string('s', 1000),
|
||||
["B"] = new byte[300],
|
||||
["I"] = 7,
|
||||
["L"] = 7L,
|
||||
["D"] = 1.5,
|
||||
["F"] = true,
|
||||
["T"] = DateTime.UtcNow,
|
||||
["G"] = Guid.NewGuid(),
|
||||
["N"] = null,
|
||||
};
|
||||
|
||||
var estimate = TableTransactionBatcher.EstimateEntityBytes(e);
|
||||
|
||||
var expected = 256 + 2 * 2 + 2 * 2 // envelope + keys
|
||||
+ (2 + 2000) // "S"
|
||||
+ (2 + 400) // "B": 300 bytes → 400 base64 chars
|
||||
+ 6 * (2 + 48) // I L D F T G
|
||||
+ (2 + 0); // N
|
||||
Assert.Equal(expected, estimate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Estimate_of_a_chunked_payload_counts_every_chunk_and_its_name()
|
||||
{
|
||||
var e = new TableEntity("pk", "rk");
|
||||
foreach (var kv in TableStorageChunking.ChunkProperty("PayloadJson", new string('x', 75_000)))
|
||||
e[kv.Key] = kv.Value;
|
||||
|
||||
var estimate = TableTransactionBatcher.EstimateEntityBytes(e);
|
||||
|
||||
Assert.True(estimate > 150_000, $"estimate {estimate} must cover 75k UTF-16 chars");
|
||||
Assert.True(estimate < TableTransactionBatcher.EntityByteLimit);
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -20,8 +20,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// </summary>
|
||||
public sealed class TableDecisionTransitionRepository : IDecisionTransitionRepository
|
||||
{
|
||||
/// <summary>Azure Table Storage limit per entity-group-transaction.</summary>
|
||||
internal const int TransactionChunkSize = 100;
|
||||
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly ILogger<TableDecisionTransitionRepository> _logger;
|
||||
@@ -60,18 +58,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"DecisionTransitions: dropped {Dropped} duplicate-RowKey row(s) (last-wins) for {Tenant}_{Session} — agent likely replayed overlapping StepIndex",
|
||||
dropped, group.Key.TenantId, group.Key.SessionId);
|
||||
|
||||
for (var offset = 0; offset < deduped.Count; offset += TransactionChunkSize)
|
||||
// Byte-aware batching (100 actions / 3.5 MB per transaction / 1 MB per entity);
|
||||
// storage failures propagate classified — the ingest maps them to 413 / 503 / 500.
|
||||
foreach (var actions in TableTransactionBatcher.Split(deduped, TableTransactionActionType.UpsertReplace))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = deduped.Skip(offset).Take(TransactionChunkSize).ToList();
|
||||
var actions = chunk
|
||||
.Select(e => new TableTransactionAction(
|
||||
TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
|
||||
committed += chunk.Count;
|
||||
committed += actions.Count;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
|
||||
+4
-12
@@ -20,9 +20,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// </summary>
|
||||
public sealed class TableSignalRepository : ISignalRepository
|
||||
{
|
||||
/// <summary>Azure Table Storage limit per entity-group-transaction.</summary>
|
||||
internal const int TransactionChunkSize = 100;
|
||||
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly ILogger<TableSignalRepository> _logger;
|
||||
|
||||
@@ -59,18 +56,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"Signals: dropped {Dropped} duplicate-RowKey row(s) (last-wins) for {Tenant}_{Session} — agent likely replayed overlapping SessionSignalOrdinal",
|
||||
dropped, group.Key.TenantId, group.Key.SessionId);
|
||||
|
||||
for (var offset = 0; offset < deduped.Count; offset += TransactionChunkSize)
|
||||
// Byte-aware batching (100 actions / 3.5 MB per transaction / 1 MB per entity);
|
||||
// storage failures propagate classified — the ingest maps them to 413 / 503 / 500.
|
||||
foreach (var actions in TableTransactionBatcher.Split(deduped, TableTransactionActionType.UpsertReplace))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = deduped.Skip(offset).Take(TransactionChunkSize).ToList();
|
||||
var actions = chunk
|
||||
.Select(e => new TableTransactionAction(
|
||||
TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
|
||||
committed += chunk.Count;
|
||||
committed += actions.Count;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
|
||||
+7
-2
@@ -13,14 +13,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// </summary>
|
||||
public static class TableStorageChunking
|
||||
{
|
||||
private const int DefaultMaxChunkSize = 30_000;
|
||||
/// <summary>
|
||||
/// Largest string kept in one property: 30,000 UTF-16 chars stay under the 64 KiB
|
||||
/// per-property limit with headroom. The event writer truncates to the same bound;
|
||||
/// <see cref="TableTransactionBatcher"/> guards the entity and transaction totals.
|
||||
/// </summary>
|
||||
public const int MaxPropertyChars = 30_000;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a string value into chunked properties if it exceeds maxChunkSize.
|
||||
/// Small values: { "Prop": value }
|
||||
/// Large values: { "Prop_0": chunk0, "Prop_1": chunk1, "Prop_ChunkCount": "2" }
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> ChunkProperty(string propertyName, string value, int maxChunkSize = DefaultMaxChunkSize)
|
||||
public static Dictionary<string, string> ChunkProperty(string propertyName, string value, int maxChunkSize = MaxPropertyChars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxChunkSize)
|
||||
return new Dictionary<string, string> { { propertyName, value ?? "" } };
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits entities into entity-group transactions that respect all three Azure Tables
|
||||
/// limits at once: at most 100 actions per transaction, a transaction payload under 4 MiB
|
||||
/// and a single entity under 1 MiB. Every other writer used to chunk by count only, which
|
||||
/// let 100 payload-heavy rows (Signals with chunked PayloadJson, Events with 30k-char
|
||||
/// Message + DataJson) exceed the 4 MiB cap and surface as an opaque 500 the agent
|
||||
/// retries forever (audit 2026-09-05 F07).
|
||||
/// <para>
|
||||
/// Sizes are estimates on the safe side (UTF-16 width for strings, base64 growth for
|
||||
/// binaries, a fixed envelope per entity); the budget leaves headroom for the OData
|
||||
/// batch framing. A batch never spans partitions: a PartitionKey change starts a new
|
||||
/// transaction, so callers may pass a pre-grouped sequence and rely on the order.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TableTransactionBatcher
|
||||
{
|
||||
/// <summary>Azure Tables hard limit on actions per entity-group transaction.</summary>
|
||||
public const int MaxActionsPerTransaction = 100;
|
||||
|
||||
/// <summary>Conservative payload budget per transaction (service limit: 4 MiB).</summary>
|
||||
public const int TransactionByteBudget = 3_500_000;
|
||||
|
||||
/// <summary>Conservative size guard per entity (service limit: 1 MiB).</summary>
|
||||
public const int EntityByteLimit = 1_000_000;
|
||||
|
||||
private const int EntityEnvelopeBytes = 256;
|
||||
private const int ScalarPropertyBytes = 48;
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="entities"/> into transactions of <paramref name="actionType"/>
|
||||
/// actions. Preserves order; starts a new transaction on the action cap, the byte
|
||||
/// budget or a PartitionKey change. Throws <see cref="TableEntityTooLargeException"/>
|
||||
/// for an entity that can never fit on its own — splitting cannot fix that, the caller
|
||||
/// has to shrink or reject the row.
|
||||
/// </summary>
|
||||
public static List<List<TableTransactionAction>> Split(
|
||||
IEnumerable<TableEntity> entities,
|
||||
TableTransactionActionType actionType,
|
||||
int maxActions = MaxActionsPerTransaction,
|
||||
int byteBudget = TransactionByteBudget)
|
||||
{
|
||||
if (entities == null) throw new ArgumentNullException(nameof(entities));
|
||||
if (maxActions < 1 || maxActions > MaxActionsPerTransaction)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxActions));
|
||||
if (byteBudget < 1) throw new ArgumentOutOfRangeException(nameof(byteBudget));
|
||||
|
||||
var batches = new List<List<TableTransactionAction>>();
|
||||
var current = new List<TableTransactionAction>();
|
||||
var currentBytes = 0;
|
||||
string? currentPartition = null;
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var bytes = EstimateEntityBytes(entity);
|
||||
if (bytes > EntityByteLimit)
|
||||
throw new TableEntityTooLargeException(entity.PartitionKey, entity.RowKey, bytes);
|
||||
|
||||
var partitionChanged = currentPartition != null
|
||||
&& !string.Equals(currentPartition, entity.PartitionKey, StringComparison.Ordinal);
|
||||
if (current.Count > 0
|
||||
&& (partitionChanged || current.Count >= maxActions || currentBytes + bytes > byteBudget))
|
||||
{
|
||||
batches.Add(current);
|
||||
current = new List<TableTransactionAction>();
|
||||
currentBytes = 0;
|
||||
}
|
||||
|
||||
current.Add(new TableTransactionAction(actionType, entity));
|
||||
currentBytes += bytes;
|
||||
currentPartition = entity.PartitionKey;
|
||||
}
|
||||
|
||||
if (current.Count > 0) batches.Add(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper-bound estimate of the serialized size of one entity: UTF-16 width for strings
|
||||
/// (property names included), base64 growth for binaries, a fixed cost for every
|
||||
/// scalar, plus the per-entity envelope.
|
||||
/// </summary>
|
||||
public static int EstimateEntityBytes(TableEntity entity)
|
||||
{
|
||||
if (entity == null) throw new ArgumentNullException(nameof(entity));
|
||||
|
||||
long total = EntityEnvelopeBytes
|
||||
+ 2L * (entity.PartitionKey?.Length ?? 0)
|
||||
+ 2L * (entity.RowKey?.Length ?? 0);
|
||||
|
||||
foreach (var kv in entity)
|
||||
{
|
||||
// TableEntity enumerates the keys as properties too; they are counted above.
|
||||
if (kv.Key == "PartitionKey" || kv.Key == "RowKey") continue;
|
||||
|
||||
total += 2L * kv.Key.Length;
|
||||
total += kv.Value switch
|
||||
{
|
||||
null => 0,
|
||||
string s => 2L * s.Length,
|
||||
byte[] b => (b.Length * 4L + 2) / 3,
|
||||
BinaryData bd => (bd.ToMemory().Length * 4L + 2) / 3,
|
||||
_ => ScalarPropertyBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return total > int.MaxValue ? int.MaxValue : (int)total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entity exceeds what Azure Tables accepts in one row. Permanent by nature: no
|
||||
/// retry and no smaller batch can make it fit. The ingest maps it to HTTP 413 so the agent
|
||||
/// isolates the item instead of replaying the batch.
|
||||
/// </summary>
|
||||
public sealed class TableEntityTooLargeException : Exception
|
||||
{
|
||||
public string PartitionKey { get; }
|
||||
public string RowKey { get; }
|
||||
public int EstimatedBytes { get; }
|
||||
|
||||
public TableEntityTooLargeException(string partitionKey, string rowKey, int estimatedBytes)
|
||||
: base($"Entity {partitionKey}/{rowKey} is about {estimatedBytes} bytes, above the {TableTransactionBatcher.EntityByteLimit}-byte entity limit")
|
||||
{
|
||||
PartitionKey = partitionKey;
|
||||
RowKey = rowKey;
|
||||
EstimatedBytes = estimatedBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -303,6 +305,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
SignalRMessages = outcome.SignalRMessages,
|
||||
};
|
||||
}
|
||||
catch (TableEntityTooLargeException ex)
|
||||
{
|
||||
// One item can never fit a table row. 413 makes the agent halve the batch and,
|
||||
// once the item is alone, quarantine it — a 500 would be replayed forever.
|
||||
_logger.LogWarning(ex, "IngestTelemetry: telemetry item {Partition}/{Row} exceeds the entity limit ({Bytes} bytes)",
|
||||
ex.PartitionKey, ex.RowKey, ex.EstimatedBytes);
|
||||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.RequestEntityTooLarge,
|
||||
"A telemetry item exceeds the storage entity limit"));
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
var (status, message, retryAfter) = ClassifyStorageFailure(ex);
|
||||
if (status == HttpStatusCode.InternalServerError)
|
||||
_logger.LogError(ex, "IngestTelemetry: storage request failed with status {Status} ({Code})", ex.Status, ex.ErrorCode);
|
||||
else
|
||||
_logger.LogWarning(ex, "IngestTelemetry: storage request failed with status {Status} ({Code}) — answering {Http}", ex.Status, ex.ErrorCode, (int)status);
|
||||
return AsOutput(await WriteErrorAsync(req, status, message, retryAfter));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "IngestTelemetry: unhandled exception");
|
||||
@@ -310,6 +330,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Seconds the agent should wait before replaying a batch after a storage outage.</summary>
|
||||
internal const int StorageRetryAfterSeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a storage failure onto the status the agent's uploader understands: 413 makes it
|
||||
/// halve the batch, 503 (with <c>Retry-After</c>) makes it replay later; the agent treats
|
||||
/// 500 as transient too but never shrinks on it, so an oversize batch surfacing as 500
|
||||
/// would wedge the device (audit 2026-09-05 F07).
|
||||
/// </summary>
|
||||
internal static (HttpStatusCode Status, string Message, int? RetryAfterSeconds) ClassifyStorageFailure(RequestFailedException ex)
|
||||
{
|
||||
if (StorageErrors.IsPayloadTooLarge(ex))
|
||||
return (HttpStatusCode.RequestEntityTooLarge, "Telemetry batch exceeds the storage transaction limit; retry with a smaller batch", null);
|
||||
if (StorageErrors.IsTransient(ex))
|
||||
return (HttpStatusCode.ServiceUnavailable, "Storage temporarily unavailable; retry later", StorageRetryAfterSeconds);
|
||||
return (HttpStatusCode.InternalServerError, "Internal server error", null);
|
||||
}
|
||||
|
||||
private static IngestEventsOutput AsOutput(HttpResponseData response)
|
||||
=> new IngestEventsOutput
|
||||
{
|
||||
@@ -645,9 +683,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteErrorAsync(
|
||||
HttpRequestData req, HttpStatusCode status, string message)
|
||||
HttpRequestData req, HttpStatusCode status, string message, int? retryAfterSeconds = null)
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
if (retryAfterSeconds is int seconds)
|
||||
response.Headers.Add("Retry-After", seconds.ToString());
|
||||
await response.WriteAsJsonAsync(new IngestEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Azure;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// One place that classifies <see cref="RequestFailedException"/> from Azure Storage. Writers
|
||||
/// decide "retry / split / give up" on these predicates and the ingest maps them to the HTTP
|
||||
/// status the agent understands: 503 for transient (replay the batch later), 413 for payload
|
||||
/// too large (halve the batch), anything else 500. The transient list mirrors
|
||||
/// <see cref="ResiliencePolicies"/>' HTTP set so both stay visibly aligned.
|
||||
/// </summary>
|
||||
public static class StorageErrors
|
||||
{
|
||||
private static readonly int[] TransientStatuses = [408, 429, 500, 502, 503, 504];
|
||||
|
||||
private static readonly string[] PayloadTooLargeCodes =
|
||||
[
|
||||
"RequestBodyTooLarge",
|
||||
"EntityTooLarge",
|
||||
"PropertyValueTooLarge",
|
||||
];
|
||||
|
||||
/// <summary>Throttling, timeouts and service-side failures — a later retry can succeed.</summary>
|
||||
public static bool IsTransient(RequestFailedException ex)
|
||||
=> ex != null && Array.IndexOf(TransientStatuses, ex.Status) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// The transaction or entity exceeds a size limit. HTTP 413 in production; some backends
|
||||
/// report the size codes with a 400, so the error code is checked as well.
|
||||
/// </summary>
|
||||
public static bool IsPayloadTooLarge(RequestFailedException ex)
|
||||
{
|
||||
if (ex == null) return false;
|
||||
if (ex.Status == 413) return true;
|
||||
return ex.ErrorCode != null && Array.IndexOf(PayloadTooLargeCodes, ex.ErrorCode) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A conditional Add hit an existing row. Production Azure Tables answers 409; the Azurite
|
||||
/// emulator and older service versions answer 400 with the same error code.
|
||||
/// </summary>
|
||||
public static bool IsAlreadyExists(RequestFailedException ex)
|
||||
{
|
||||
if (ex == null) return false;
|
||||
if (ex.Status == 409) return true;
|
||||
return ex.Status == 400 && string.Equals(ex.ErrorCode, "EntityAlreadyExists", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -12,6 +12,7 @@ using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -237,7 +238,7 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
{
|
||||
await tableClient.AddEntityAsync(entity, handlerCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (RequestFailedException ex) when (IsAlreadyExistsStatus(ex))
|
||||
catch (RequestFailedException ex) when (StorageErrors.IsAlreadyExists(ex))
|
||||
{
|
||||
throw new BackupTerminalException(
|
||||
"CurrentRowChanged",
|
||||
@@ -484,11 +485,5 @@ namespace AutopilotMonitor.Functions.Services.Backup
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
@@ -104,22 +106,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
foreach (var group in rows.GroupBy(r => r.Pk, StringComparer.Ordinal))
|
||||
{
|
||||
var rowsInGroup = group.ToList();
|
||||
for (var i = 0; i < rowsInGroup.Count; i += RestoreBatchActionLimit)
|
||||
var entities = group.Select(dump => ConvertDumpToEntity(dump, tableName)).ToList();
|
||||
foreach (var actions in TableTransactionBatcher.Split(entities, TableTransactionActionType.Add))
|
||||
{
|
||||
var chunk = rowsInGroup.Skip(i).Take(RestoreBatchActionLimit).ToList();
|
||||
var actions = chunk
|
||||
.Select(dump => new TableTransactionAction(
|
||||
TableTransactionActionType.Add,
|
||||
ConvertDumpToEntity(dump, tableName)))
|
||||
.ToList();
|
||||
|
||||
var chunk = actions.Select(a => (TableEntity)a.Entity).ToList();
|
||||
try
|
||||
{
|
||||
await tableClient.SubmitTransactionAsync(actions, cancellationToken);
|
||||
restored += chunk.Count;
|
||||
}
|
||||
catch (RequestFailedException ex) when (IsAlreadyExistsStatus(ex))
|
||||
catch (RequestFailedException ex) when (StorageErrors.IsAlreadyExists(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
|
||||
@@ -130,15 +126,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// retries after partial-failed restore). The `mode` parameter is kept for
|
||||
// API readability but no longer changes behaviour here.
|
||||
_ = mode; // intentional: relaxation documented above
|
||||
foreach (var dump in chunk)
|
||||
foreach (var entity in chunk)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await tableClient.AddEntityAsync(ConvertDumpToEntity(dump, tableName), cancellationToken);
|
||||
await tableClient.AddEntityAsync(entity, cancellationToken);
|
||||
restored++;
|
||||
}
|
||||
catch (RequestFailedException rfe) when (IsAlreadyExistsStatus(rfe))
|
||||
catch (RequestFailedException rfe) when (StorageErrors.IsAlreadyExists(rfe))
|
||||
{
|
||||
// Mirror the batch-level relaxation: some Azure deployments and
|
||||
// the Azurite emulator surface duplicate-row conflict as HTTP 400
|
||||
@@ -245,10 +241,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return entity;
|
||||
}
|
||||
|
||||
// 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>
|
||||
/// Bulk-restore helper for the critical-table backup feature (plan §PR0). Symmetric to
|
||||
/// <see cref="RestoreRowsByExactKeysInBatchesAsync"/> but uses
|
||||
@@ -275,37 +267,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
foreach (var group in rows.GroupBy(r => r.Pk, StringComparer.Ordinal))
|
||||
{
|
||||
var rowsInGroup = group.ToList();
|
||||
for (var i = 0; i < rowsInGroup.Count; i += RestoreBatchActionLimit)
|
||||
var entities = group.Select(dump => ConvertDumpToEntity(dump, tableName)).ToList();
|
||||
foreach (var actions in TableTransactionBatcher.Split(entities, TableTransactionActionType.UpsertReplace))
|
||||
{
|
||||
var chunk = rowsInGroup.Skip(i).Take(RestoreBatchActionLimit).ToList();
|
||||
var actions = chunk
|
||||
.Select(dump => new TableTransactionAction(
|
||||
TableTransactionActionType.UpsertReplace,
|
||||
ConvertDumpToEntity(dump, tableName)))
|
||||
.ToList();
|
||||
|
||||
await tableClient.SubmitTransactionAsync(actions, cancellationToken);
|
||||
upserted += chunk.Count;
|
||||
upserted += actions.Count;
|
||||
}
|
||||
}
|
||||
|
||||
return upserted;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,19 +1067,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
entitiesToUpsert[rowKey] = entity;
|
||||
}
|
||||
|
||||
// Batch write (max 100 per batch, all same PK)
|
||||
// Batch write (byte-aware, all same PK)
|
||||
var upsertList = entitiesToUpsert.Values.ToList();
|
||||
for (int i = 0; i < upsertList.Count; i += 100)
|
||||
{
|
||||
var batch = upsertList.Skip(i).Take(100)
|
||||
.Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
}
|
||||
foreach (var batch in TableTransactionBatcher.Split(upsertList, TableTransactionActionType.UpsertReplace))
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
|
||||
_logger.LogInformation("Upserted {Count} software inventory entries for tenant {TenantId}", upsertList.Count, tenantId);
|
||||
}
|
||||
@@ -1201,18 +1192,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
});
|
||||
}
|
||||
|
||||
// Batch write (max 100 per batch, all same PK)
|
||||
for (int i = 0; i < entities.Count; i += 100)
|
||||
{
|
||||
var batch = entities.Skip(i).Take(100)
|
||||
.Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
}
|
||||
// Batch write (byte-aware, all same PK)
|
||||
foreach (var batch in TableTransactionBatcher.Split(entities, TableTransactionActionType.UpsertReplace))
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
|
||||
_logger.LogInformation("Imported {Count} CPE seed mapping entries from GitHub JSON into VulnerabilityCache table", entities.Count);
|
||||
return entities.Count;
|
||||
@@ -1270,18 +1252,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
});
|
||||
}
|
||||
|
||||
// Batch write (max 100 per batch, all same PK)
|
||||
for (int i = 0; i < entities.Count; i += 100)
|
||||
{
|
||||
var batch = entities.Skip(i).Take(100)
|
||||
.Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
}
|
||||
// Batch write (byte-aware, all same PK)
|
||||
foreach (var batch in TableTransactionBatcher.Split(entities, TableTransactionActionType.UpsertReplace))
|
||||
await tableClient.SubmitTransactionAsync(batch);
|
||||
|
||||
_logger.LogInformation("Imported {Count} CPE community mapping entries from GitHub JSON into VulnerabilityCache table", entities.Count);
|
||||
return entities.Count;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
@@ -428,10 +429,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Azure Table Storage limits string properties to 64KB (32K UTF-16 chars).
|
||||
/// Truncate to 30,000 chars to leave buffer for multi-byte characters.
|
||||
/// Azure Table Storage limits string properties to 64KB (32K UTF-16 chars). Events are
|
||||
/// truncated rather than chunked; the bound is shared with <see cref="TableStorageChunking"/>.
|
||||
/// </summary>
|
||||
private const int MaxTableStorageStringLength = 30000;
|
||||
private const int MaxTableStorageStringLength = TableStorageChunking.MaxPropertyChars;
|
||||
|
||||
private string TruncateForTableStorage(string value, string propertyName, string eventId)
|
||||
{
|
||||
@@ -847,6 +848,60 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Stores an event
|
||||
/// </summary>
|
||||
/// <summary>RowKey of an event row: device event time (ms) plus the agent sequence.</summary>
|
||||
private static string BuildEventRowKey(EnrollmentEvent evt)
|
||||
=> $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}";
|
||||
|
||||
/// <summary>
|
||||
/// Projects an event onto its Events row. Shared by the single and the batched writer so
|
||||
/// the two can never drift. PartitionKey <c>{TenantId}_{SessionId}</c>, RowKey
|
||||
/// <see cref="BuildEventRowKey"/>.
|
||||
/// </summary>
|
||||
private TableEntity BuildEventEntity(EnrollmentEvent evt)
|
||||
{
|
||||
var entity = new TableEntity($"{evt.TenantId}_{evt.SessionId}", BuildEventRowKey(evt))
|
||||
{
|
||||
["EventId"] = evt.EventId,
|
||||
["SessionId"] = evt.SessionId,
|
||||
["TenantId"] = evt.TenantId,
|
||||
// "Timestamp" is a reserved system property — supplied values are ignored
|
||||
// and any row rewrite (storage migration) resets it. The sanitized agent
|
||||
// event time therefore lives in its own column.
|
||||
[BusinessTimestamp.OccurredUtcColumn] = EnsureUtc(evt.Timestamp),
|
||||
["EventType"] = evt.EventType ?? string.Empty,
|
||||
["Severity"] = (int)evt.Severity,
|
||||
["Source"] = evt.Source ?? string.Empty,
|
||||
["Phase"] = (int)evt.Phase,
|
||||
["Message"] = TruncateForTableStorage(evt.Message ?? string.Empty, "Message", evt.EventId),
|
||||
["Sequence"] = evt.Sequence,
|
||||
["DataJson"] = TruncateForTableStorage(
|
||||
evt.Data != null && evt.Data.Count > 0
|
||||
? JsonConvert.SerializeObject(evt.Data)
|
||||
: string.Empty,
|
||||
"DataJson", evt.EventId),
|
||||
["ReceivedAt"] = evt.ReceivedAt,
|
||||
["TimestampClamped"] = evt.TimestampClamped
|
||||
};
|
||||
|
||||
// P14: request-level device-clock send time; column stays unset for
|
||||
// events from agents that pre-date the X-Send-Time-Utc header.
|
||||
if (evt.SentAt.HasValue)
|
||||
entity["SentAt"] = EnsureUtc(evt.SentAt.Value);
|
||||
|
||||
if (evt.OriginalTimestamp.HasValue)
|
||||
entity["OriginalTimestamp"] = EnsureUtc(evt.OriginalTimestamp.Value);
|
||||
|
||||
// Codex follow-up #3: forward-link columns. Null when absent on the agent
|
||||
// payload (pre-#3 events, or events emitted outside the reducer pipeline);
|
||||
// skip the entity setter in that case so the column stays unset.
|
||||
if (evt.CausedByTransitionStepIndex.HasValue)
|
||||
entity["CausedByTransitionStepIndex"] = evt.CausedByTransitionStepIndex.Value;
|
||||
if (evt.CausedBySignalOrdinal.HasValue)
|
||||
entity["CausedBySignalOrdinal"] = evt.CausedBySignalOrdinal.Value;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task<bool> StoreEventAsync(EnrollmentEvent evt)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId");
|
||||
@@ -856,52 +911,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
|
||||
|
||||
// PartitionKey: TenantId_SessionId for efficient querying
|
||||
// RowKey: Timestamp_Sequence for ordering
|
||||
var partitionKey = $"{evt.TenantId}_{evt.SessionId}";
|
||||
var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}";
|
||||
|
||||
var entity = new TableEntity(partitionKey, rowKey)
|
||||
{
|
||||
["EventId"] = evt.EventId,
|
||||
["SessionId"] = evt.SessionId,
|
||||
["TenantId"] = evt.TenantId,
|
||||
// "Timestamp" is a reserved system property — supplied values are ignored
|
||||
// and any row rewrite (storage migration) resets it. The sanitized agent
|
||||
// event time therefore lives in its own column.
|
||||
[BusinessTimestamp.OccurredUtcColumn] = EnsureUtc(evt.Timestamp),
|
||||
["EventType"] = evt.EventType ?? string.Empty,
|
||||
["Severity"] = (int)evt.Severity,
|
||||
["Source"] = evt.Source ?? string.Empty,
|
||||
["Phase"] = (int)evt.Phase,
|
||||
["Message"] = TruncateForTableStorage(evt.Message ?? string.Empty, "Message", evt.EventId),
|
||||
["Sequence"] = evt.Sequence,
|
||||
["DataJson"] = TruncateForTableStorage(
|
||||
evt.Data != null && evt.Data.Count > 0
|
||||
? JsonConvert.SerializeObject(evt.Data)
|
||||
: string.Empty,
|
||||
"DataJson", evt.EventId),
|
||||
["ReceivedAt"] = evt.ReceivedAt,
|
||||
["TimestampClamped"] = evt.TimestampClamped
|
||||
};
|
||||
|
||||
// P14: request-level device-clock send time; column stays unset for
|
||||
// events from agents that pre-date the X-Send-Time-Utc header.
|
||||
if (evt.SentAt.HasValue)
|
||||
entity["SentAt"] = EnsureUtc(evt.SentAt.Value);
|
||||
|
||||
if (evt.OriginalTimestamp.HasValue)
|
||||
entity["OriginalTimestamp"] = EnsureUtc(evt.OriginalTimestamp.Value);
|
||||
|
||||
// Codex follow-up #3: forward-link columns. Null when absent on the agent
|
||||
// payload (pre-#3 events, or events emitted outside the reducer pipeline);
|
||||
// skip the entity setter in that case so the column stays unset.
|
||||
if (evt.CausedByTransitionStepIndex.HasValue)
|
||||
entity["CausedByTransitionStepIndex"] = evt.CausedByTransitionStepIndex.Value;
|
||||
if (evt.CausedBySignalOrdinal.HasValue)
|
||||
entity["CausedBySignalOrdinal"] = evt.CausedBySignalOrdinal.Value;
|
||||
|
||||
await tableClient.UpsertEntityAsync(entity);
|
||||
await tableClient.UpsertEntityAsync(BuildEventEntity(evt));
|
||||
_logger.LogDebug($"Stored event {evt.EventId}");
|
||||
|
||||
return true;
|
||||
@@ -940,84 +950,55 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// Chunk into batches of 100 (Azure Table Storage limit)
|
||||
var chunks = group.Select((evt, index) => new { evt, index })
|
||||
.GroupBy(x => x.index / 100)
|
||||
.Select(g => g.Select(x => x.evt).ToList());
|
||||
// Dedup by RowKey within the partition. The RowKey is {Timestamp:ms}_{Sequence:D10};
|
||||
// two events sharing the same millisecond AND Sequence map to one row and would
|
||||
// collapse under UpsertReplace anyway — but Azure rejects the WHOLE transaction
|
||||
// with InvalidDuplicateRow if both appear in one batch. Keeping the last
|
||||
// occurrence (matches UpsertReplace last-wins) lets the batch succeed cleanly.
|
||||
var deduped = group
|
||||
.GroupBy(BuildEventRowKey)
|
||||
.Select(g => g.Last())
|
||||
.ToList();
|
||||
|
||||
foreach (var rawChunk in chunks)
|
||||
// Byte-aware batching: 100 actions, 3.5 MB per transaction, 1 MB per entity.
|
||||
// An entity that can never fit throws TableEntityTooLargeException — permanent,
|
||||
// the ingest maps it to 413 so the agent isolates the item.
|
||||
var eventsByEntity = new Dictionary<TableEntity, EnrollmentEvent>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var evt in deduped)
|
||||
eventsByEntity[BuildEventEntity(evt)] = evt;
|
||||
|
||||
foreach (var actions in TableTransactionBatcher.Split(eventsByEntity.Keys, TableTransactionActionType.UpsertReplace))
|
||||
{
|
||||
// Dedup by RowKey within the chunk. The RowKey is {Timestamp:ms}_{Sequence:D10};
|
||||
// two events sharing the same millisecond AND Sequence map to one row and would
|
||||
// collapse under UpsertReplace anyway — but Azure rejects the WHOLE transaction
|
||||
// with InvalidDuplicateRow if both appear in one batch (then we degrade to slow
|
||||
// per-event writes and track a noisy TableTransactionFailedException). Keeping the
|
||||
// last occurrence (matches UpsertReplace last-wins) lets the batch succeed cleanly.
|
||||
var chunk = rawChunk
|
||||
.GroupBy(evt => $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}")
|
||||
.Select(g => g.Last())
|
||||
.ToList();
|
||||
|
||||
var chunk = actions.Select(a => eventsByEntity[(TableEntity)a.Entity]).ToList();
|
||||
try
|
||||
{
|
||||
var actions = chunk.Select(evt =>
|
||||
{
|
||||
var partitionKey = $"{evt.TenantId}_{evt.SessionId}";
|
||||
var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}";
|
||||
|
||||
var entity = new TableEntity(partitionKey, rowKey)
|
||||
{
|
||||
["EventId"] = evt.EventId,
|
||||
["SessionId"] = evt.SessionId,
|
||||
["TenantId"] = evt.TenantId,
|
||||
// Reserved-name caveat: see StoreEventAsync.
|
||||
[BusinessTimestamp.OccurredUtcColumn] = EnsureUtc(evt.Timestamp),
|
||||
["EventType"] = evt.EventType ?? string.Empty,
|
||||
["Severity"] = (int)evt.Severity,
|
||||
["Source"] = evt.Source ?? string.Empty,
|
||||
["Phase"] = (int)evt.Phase,
|
||||
["Message"] = TruncateForTableStorage(evt.Message ?? string.Empty, "Message", evt.EventId),
|
||||
["Sequence"] = evt.Sequence,
|
||||
["DataJson"] = TruncateForTableStorage(
|
||||
evt.Data != null && evt.Data.Count > 0
|
||||
? JsonConvert.SerializeObject(evt.Data)
|
||||
: string.Empty,
|
||||
"DataJson", evt.EventId),
|
||||
["ReceivedAt"] = evt.ReceivedAt,
|
||||
["TimestampClamped"] = evt.TimestampClamped
|
||||
};
|
||||
|
||||
// P14: see StoreEventAsync.
|
||||
if (evt.SentAt.HasValue)
|
||||
entity["SentAt"] = EnsureUtc(evt.SentAt.Value);
|
||||
|
||||
if (evt.OriginalTimestamp.HasValue)
|
||||
entity["OriginalTimestamp"] = EnsureUtc(evt.OriginalTimestamp.Value);
|
||||
|
||||
// Codex follow-up #3: forward-link columns (see StoreEventAsync).
|
||||
if (evt.CausedByTransitionStepIndex.HasValue)
|
||||
entity["CausedByTransitionStepIndex"] = evt.CausedByTransitionStepIndex.Value;
|
||||
if (evt.CausedBySignalOrdinal.HasValue)
|
||||
entity["CausedBySignalOrdinal"] = evt.CausedBySignalOrdinal.Value;
|
||||
|
||||
return new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity);
|
||||
}).ToList();
|
||||
|
||||
await tableClient.SubmitTransactionAsync(actions);
|
||||
storedEvents.AddRange(chunk);
|
||||
_logger.LogDebug($"Batch stored {chunk.Count} events for partition {group.Key}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (RequestFailedException ex) when (StorageErrors.IsTransient(ex) || StorageErrors.IsPayloadTooLarge(ex))
|
||||
{
|
||||
// Batch failed - fall back to individual writes for this chunk
|
||||
_logger.LogWarning(ex, $"Batch write failed for {chunk.Count} events, falling back to individual writes");
|
||||
// Throttling / outage / oversize: per-row retries would only multiply the
|
||||
// pressure (100 upserts × SDK retries against a partition that just said
|
||||
// no). Propagate; the ingest answers 503 or 413 and the agent replays the
|
||||
// batch — every row is an idempotent UpsertReplace.
|
||||
_logger.LogWarning(ex,
|
||||
"Batch write of {Count} events for {Partition} failed with status {Status} ({Code}) — propagating, no per-row fallback",
|
||||
chunk.Count, group.Key, ex.Status, ex.ErrorCode);
|
||||
throw;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
// Data-shaped rejection (one bad row spoils the transaction): fall back to
|
||||
// per-row writes so the good rows land and the bad one is isolated.
|
||||
_logger.LogWarning(ex,
|
||||
"Batch write of {Count} events for {Partition} rejected with status {Status} ({Code}) — falling back to individual writes",
|
||||
chunk.Count, group.Key, ex.Status, ex.ErrorCode);
|
||||
|
||||
foreach (var evt in chunk)
|
||||
{
|
||||
if (await StoreEventAsync(evt))
|
||||
{
|
||||
storedEvents.Add(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user