mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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; since82919d47it 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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user