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(