feat(usage): cache the TokenTracker export with single-flight refresh

This commit is contained in:
mika kuns
2026-08-24 13:39:20 +02:00
parent a92180456d
commit 3f3638c1ce
3 changed files with 283 additions and 0 deletions
@@ -0,0 +1,47 @@
using ClaudeDo.Worker.Usage.TokenTracker;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
public sealed class FakeTokenTrackerClient : ITokenTrackerClient
{
private readonly SemaphoreSlim _gate = new(0);
public TokenTrackerProbe Probe { get; set; } = new(true, "1.2.3", true, "v22.1.0", null);
public TokenTrackerRunResult ExportResult { get; set; } =
new(true, TokenTrackerFixtures.SessionsJson, null);
public TokenTrackerRunResult InstallResult { get; set; } = new(true, "added 1 package", null);
public int ExportCalls;
public int ProbeCalls;
public int InstallCalls;
public DateOnly? LastFrom;
public DateOnly? LastTo;
/// <summary>When true, <see cref="ExportAsync"/> blocks until <see cref="Release"/> is called.</summary>
public bool BlockExport { get; set; }
public void Release() => _gate.Release();
public Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
{
Interlocked.Increment(ref ProbeCalls);
return Task.FromResult(Probe);
}
public async Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
{
Interlocked.Increment(ref ExportCalls);
LastFrom = from;
LastTo = to;
if (BlockExport) await _gate.WaitAsync(ct);
return ExportResult;
}
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default)
{
Interlocked.Increment(ref InstallCalls);
output?.Report("added 1 package");
return Task.FromResult(InstallResult);
}
}
@@ -0,0 +1,138 @@
using ClaudeDo.Worker.Usage.TokenTracker;
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
public sealed class TokenTrackerServiceTests
{
private static readonly DateTime Now = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
private static (TokenTrackerService Service, FakeTokenTrackerClient Client) Build(DateTime? now = null)
{
var client = new FakeTokenTrackerClient();
var clock = now ?? Now;
return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client);
}
[Fact]
public async Task RefreshAsync_StoresParsedExport()
{
var (service, client) = Build();
await service.RefreshAsync();
Assert.Equal(1, client.ExportCalls);
Assert.Equal(4, service.State.Export!.Sessions.Count);
Assert.Null(service.State.LastError);
}
[Fact]
public async Task RefreshAsync_RequestsTheConfiguredWindow()
{
var (service, client) = Build();
await service.RefreshAsync();
Assert.Equal(DateOnly.FromDateTime(Now.ToLocalTime()), client.LastTo);
Assert.Equal(
DateOnly.FromDateTime(Now.ToLocalTime()).AddDays(-TokenTrackerService.WindowDays),
client.LastFrom);
}
[Fact]
public async Task RefreshAsync_Failure_KeepsLastGoodExport()
{
var (service, client) = Build();
await service.RefreshAsync();
client.ExportResult = new TokenTrackerRunResult(false, "", "tokentracker exited with code 1");
await service.RefreshAsync();
Assert.Equal(4, service.State.Export!.Sessions.Count);
Assert.Equal("tokentracker exited with code 1", service.State.LastError);
}
[Fact]
public async Task RefreshAsync_UnparseableOutput_IsAnError()
{
var (service, client) = Build();
client.ExportResult = new TokenTrackerRunResult(true, "not json", null);
await service.RefreshAsync();
Assert.Null(service.State.Export);
Assert.NotNull(service.State.LastError);
}
[Fact]
public async Task RefreshAsync_UnsupportedFormatVersion_IsAnErrorAndKeepsNoData()
{
var (service, client) = Build();
client.ExportResult = new TokenTrackerRunResult(true, TokenTrackerFixtures.UnsupportedVersionJson, null);
await service.RefreshAsync();
Assert.Null(service.State.Export);
Assert.Contains("99", service.State.LastError);
}
[Fact]
public async Task EnsureFreshAsync_FreshExport_DoesNotFetchAgain()
{
var (service, client) = Build();
await service.RefreshAsync();
await service.EnsureFreshAsync(TimeSpan.FromMinutes(15));
Assert.Equal(1, client.ExportCalls);
}
[Fact]
public async Task EnsureFreshAsync_NoExportYet_Fetches()
{
var (service, client) = Build();
await service.EnsureFreshAsync(TimeSpan.FromMinutes(15));
Assert.Equal(1, client.ExportCalls);
}
[Fact]
public async Task ConcurrentRefresh_RunsTheCliOnce()
{
var (service, client) = Build();
client.BlockExport = true;
var first = service.RefreshAsync();
var second = service.RefreshAsync();
client.Release();
await Task.WhenAll(first, second);
Assert.Equal(1, client.ExportCalls);
}
[Fact]
public async Task ProbeAsync_CachesUntilForced()
{
var (service, client) = Build();
await service.ProbeAsync();
await service.ProbeAsync();
Assert.Equal(1, client.ProbeCalls);
await service.ProbeAsync(force: true);
Assert.Equal(2, client.ProbeCalls);
}
[Fact]
public async Task InstallAsync_ReprobesAfterwards()
{
var (service, client) = Build();
await service.ProbeAsync();
var result = await service.InstallAsync();
Assert.True(result.Ok);
Assert.Equal(1, client.InstallCalls);
Assert.Equal(2, client.ProbeCalls);
}
}