fix(notifications): deterministic lowest-Id SMTP config selection (+ multi-row warning); atomic OAuth2 token cache entry

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:14:14 -04:00
parent eb7a913d22
commit 39e1ca3eae
4 changed files with 74 additions and 11 deletions
@@ -56,18 +56,22 @@ public class OAuth2TokenService
var key = CredentialKey($"{credentials}|{authority}|{scope}");
var entry = _cache.GetOrAdd(key, _ => new CacheEntry());
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
// Read the token/expiry pair through a single volatile reference so a lock-free
// reader can never observe a torn token/expiry combination (arch-review S8).
var current = entry.Current;
if (current != null && DateTimeOffset.UtcNow < current.Expiry)
{
return entry.Token;
return current.Token;
}
await entry.Lock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring the per-credential lock.
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
current = entry.Current;
if (current != null && DateTimeOffset.UtcNow < current.Expiry)
{
return entry.Token;
return current.Token;
}
var parts = credentials.Split(':', 3);
@@ -109,8 +113,10 @@ public class OAuth2TokenService
?? throw new InvalidOperationException("No access_token in OAuth2 response");
var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
entry.Token = token;
entry.Expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
// Publish the token/expiry pair as a single atomic reference assignment
// (Refresh 60s before expiry).
entry.Current = new TokenValue(
token, DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60));
// The token endpoint identity is logged by tenant only — never
// the client secret or the access token itself.
@@ -134,10 +140,14 @@ public class OAuth2TokenService
return Convert.ToHexString(hash);
}
/// <summary>An atomically-published token/expiry pair — one reference, never torn.</summary>
private sealed record TokenValue(string Token, DateTimeOffset Expiry);
private sealed class CacheEntry
{
public string? Token;
public DateTimeOffset Expiry = DateTimeOffset.MinValue;
// Written only under Lock; read lock-free via the volatile reference so a reader
// always sees a self-consistent token/expiry pair (or null for "no token yet").
public volatile TokenValue? Current;
public readonly SemaphoreSlim Lock = new(1, 1);
}
}