diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsPublishTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsPublishTests.cs new file mode 100644 index 00000000..09facb75 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformStatsPublishTests.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The versioned platform-stats file is served immutable for a year, so every publish must write +/// a new name. Maintenance publishes every 2 hours; the per-day name it used to write was rewritten +/// up to 12 times a day, and caches kept the first copy of the day. +/// +public class PlatformStatsPublishTests +{ + private static readonly DateTime Publish = new(2026, 10, 3, 6, 0, 4, DateTimeKind.Utc); + + [Fact] + public void Versioned_file_name_carries_the_publish_instant_to_the_second() + { + Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish)); + } + + [Fact] + public void Two_publishes_of_one_day_get_different_names() + { + var early = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 2, 0, 3, DateTimeKind.Utc)); + var later = MaintenanceService.PlatformStatsVersionedFileName(new DateTime(2026, 10, 3, 4, 0, 3, DateTimeKind.Utc)); + + Assert.NotEqual(early, later); + } + + [Fact] + public void Versioned_file_name_ignores_the_current_culture() + { + var previous = CultureInfo.CurrentCulture; + try + { + // th-TH counts years in the Buddhist era (2569 for 2026). + CultureInfo.CurrentCulture = new CultureInfo("th-TH"); + Assert.Equal("platform-stats.2026-10-03T060004Z.json", MaintenanceService.PlatformStatsVersionedFileName(Publish)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 718175e7..60cbb1a9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -795,7 +796,7 @@ namespace AutopilotMonitor.Functions.Services var containerClient = new BlobContainerClient(new Uri(containerSasUrl)); var generatedAtUtc = DateTime.UtcNow; - var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json"; + var versionedFileName = PlatformStatsVersionedFileName(generatedAtUtc); var versionedPayload = new { @@ -831,19 +832,34 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Name of the versioned stats file of one publish. The file is served immutable for a year + /// (), so a name must never be written twice: + /// maintenance publishes every 2 hours, and the per-day name of the once-a-night schedule was + /// rewritten up to 12 times a day under that header — caches kept the first copy of the day. + /// Browsers revalidate only the manifest, which names the current file. + /// + internal static string PlatformStatsVersionedFileName(DateTime generatedAtUtc) => + $"platform-stats.{generatedAtUtc.ToString("yyyy-MM-dd'T'HHmmss'Z'", CultureInfo.InvariantCulture)}.json"; + private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl) { var blobClient = containerClient.GetBlobClient(blobName); var json = JsonConvert.SerializeObject(payload); await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - await blobClient.UploadAsync(stream, overwrite: true); - await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders + // Headers and tier travel with the upload, which still overwrites (the manifest relies on + // it). Set in follow-up calls, the blob was briefly served without Cache-Control and + // content type, and stayed that way when a follow-up call failed. + await blobClient.UploadAsync(stream, new BlobUploadOptions { - ContentType = "application/json; charset=utf-8", - CacheControl = cacheControl + HttpHeaders = new BlobHttpHeaders + { + ContentType = "application/json; charset=utf-8", + CacheControl = cacheControl + }, + AccessTier = AccessTier.Hot }); - await blobClient.SetAccessTierAsync(AccessTier.Hot); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 27252cae..d408206c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -60,6 +60,7 @@ namespace AutopilotMonitor.Functions.Services private const string PlatformStatsAliasFileName = "platform-stats.json"; private const string PlatformStatsAliasCacheControl = "public, max-age=300, stale-while-revalidate=86400"; + // Holds only because every publish writes a new name (PlatformStatsVersionedFileName). private const string PlatformStatsVersionedCacheControl = "public, max-age=31536000, immutable"; public MaintenanceService(