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,98 @@
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Owns the cached export. One fetch covers <see cref="WindowDays"/> days because
/// <c>buildSessionAnalytics</c> walks the whole history regardless of <c>--from</c>/<c>--to</c>
/// (v0.88.4 returns the same rows either way), so narrowing the request would buy nothing while
/// making every range switch pay the cost again.
/// </summary>
public sealed class TokenTrackerService
{
public const int WindowDays = 90;
private readonly ITokenTrackerClient _client;
private readonly Func<DateTime> _utcNow;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private TokenTrackerProbe? _probe;
public TokenTrackerService(ITokenTrackerClient client, TokenTrackerState state, Func<DateTime>? utcNow = null)
{
_client = client;
State = state;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
public TokenTrackerState State { get; }
public async Task<TokenTrackerProbe> ProbeAsync(bool force = false, CancellationToken ct = default)
{
if (!force && _probe is { } cached) return cached;
var probe = await _client.ProbeAsync(ct);
_probe = probe;
return probe;
}
/// <summary>Fetches only when the cached export is missing or older than
/// <paramref name="maxAge"/>. Safe to call on every modal open.</summary>
public Task EnsureFreshAsync(TimeSpan maxAge, CancellationToken ct = default) =>
State.IsOlderThan(maxAge, _utcNow()) ? RefreshAsync(ct) : Task.CompletedTask;
public async Task RefreshAsync(CancellationToken ct = default)
{
// A refresh already in flight is the refresh this caller wanted — waiting for it and
// returning is correct, and it stops a burst of range switches from spawning processes.
if (!await _refreshLock.WaitAsync(0, ct))
{
await _refreshLock.WaitAsync(ct);
_refreshLock.Release();
return;
}
try
{
var now = _utcNow();
var to = DateOnly.FromDateTime(now.ToLocalTime());
var from = to.AddDays(-WindowDays);
var run = await _client.ExportAsync(from, to, ct);
if (!run.Ok)
{
State.ReportFailure(run.Error ?? "TokenTracker export failed.", now);
return;
}
var export = TokenTrackerExportParser.Parse(run.StdOut, now);
if (export is null)
{
State.ReportFailure("TokenTracker returned output we could not parse.", now);
return;
}
if (export.FormatVersion != TokenTrackerExportParser.SupportedFormatVersion)
{
State.ReportFailure(
$"Unexpected TokenTracker export format v{export.FormatVersion} " +
$"(supported: v{TokenTrackerExportParser.SupportedFormatVersion}).",
now);
return;
}
State.ReportSuccess(export);
}
finally
{
_refreshLock.Release();
}
}
public async Task<TokenTrackerRunResult> InstallAsync(
IProgress<string>? output = null, CancellationToken ct = default)
{
var result = await _client.InstallAsync(output, ct);
await ProbeAsync(force: true, ct);
return result;
}
}