Platform stats publish writes a new versioned file each run, so its immutable header holds again

The versioned stats file is served public, max-age=31536000, immutable, and
the manifest platform-stats.json names the current one. Its per-day name
(19e9713a) was unique while maintenance ran once a night; since 82919d47 it
runs every 2 hours and rewrote that file up to 12 times a day, so caches kept
the first copy of the day. The name now carries the publish instant to the
second in invariant culture (platform-stats.2026-10-03T060004Z.json).

Headers and access tier travel with the upload instead of two follow-up
calls, which left the blob briefly without Cache-Control and content type,
and kept it that way (with the manifest still on the old file) when one of
them failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
This commit is contained in:
Claude
2026-10-03 04:54:54 +00:00
parent 762fef4e24
commit e537a4b074
3 changed files with 68 additions and 6 deletions
@@ -0,0 +1,45 @@
using System.Globalization;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
@@ -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
}
}
/// <summary>
/// Name of the versioned stats file of one publish. The file is served immutable for a year
/// (<see cref="PlatformStatsVersionedCacheControl"/>), 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.
/// </summary>
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);
}
/// <summary>
@@ -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(