diff --git a/readme.md b/readme.md index 080b528..161fb75 100644 --- a/readme.md +++ b/readme.md @@ -54,12 +54,22 @@ Frequency of updates to data is relatively low compared to reads ```mermaid graph TD Request + HasMaxAge{Has max-age
or max-stale
in Cache-Control?} + CacheValid{Cached timestamp
within allowed
staleness?} + FreshTimestamp[Get fresh timestamp
from SQL] + UseCached[Use cached timestamp] CalculateEtag[Calculate current ETag
based on timestamp
from web assembly and SQL] IfNoneMatch{Has
If-None-Match
header?} EtagMatch{Current
Etag matches
If-None-Match?} AddETag[Add current ETag
to Response headers] 304[Respond with
304 Not-Modified] - Request --> CalculateEtag + Request --> HasMaxAge + HasMaxAge -->|Yes| CacheValid + HasMaxAge -->|No| FreshTimestamp + CacheValid -->|Yes| UseCached + CacheValid -->|No| FreshTimestamp + UseCached --> CalculateEtag + FreshTimestamp --> CalculateEtag CalculateEtag --> IfNoneMatch IfNoneMatch -->|Yes| EtagMatch IfNoneMatch -->|No| AddETag @@ -135,7 +145,7 @@ internal static string BuildEtag(string timeStamp, string? suffix) return $"\"{AssemblyWriteTime}-{timeStamp}-{suffix}\""; } ``` -snippet source | anchor +snippet source | anchor @@ -168,6 +178,31 @@ Documentation is specific to choice of database: * [EF with PostgreSQL Docs](/docs/postgres-ef.md) when using the [PostgreSQL EF Database Provider](https://www.npgsql.org/efcore) +## Timestamp caching via request Cache-Control + +By default, Delta queries the database on every GET request to retrieve the current timestamp. Clients can opt into timestamp caching by sending `Cache-Control` request headers, allowing Delta to skip the database round-trip when a recent enough cached timestamp exists. + +Supported directives: + + * `max-age=N` — accept a cached timestamp up to N seconds old + * `max-stale=N` — accept a cached timestamp up to N seconds old + * `max-stale` (no value) — accept any cached timestamp regardless of age + +If both `max-age` and `max-stale` are present, the larger (more permissive) value is used. + +Requests without these directives always query the database (existing behavior). + +Example request headers: + +``` +Cache-Control: max-age=5 +``` + +``` +Cache-Control: max-stale +``` + + ## UseResponseDiagnostics Response diagnostics is an opt-out feature that includes extra log information in the response headers. Consider disabling in production to avoid adding diagnostic headers to every response. diff --git a/readme.source.md b/readme.source.md index 3a48d2d..669ead8 100644 --- a/readme.source.md +++ b/readme.source.md @@ -33,12 +33,22 @@ Frequency of updates to data is relatively low compared to reads ```mermaid graph TD Request + HasMaxAge{Has max-age
or max-stale
in Cache-Control?} + CacheValid{Cached timestamp
within allowed
staleness?} + FreshTimestamp[Get fresh timestamp
from SQL] + UseCached[Use cached timestamp] CalculateEtag[Calculate current ETag
based on timestamp
from web assembly and SQL] IfNoneMatch{Has
If-None-Match
header?} EtagMatch{Current
Etag matches
If-None-Match?} AddETag[Add current ETag
to Response headers] 304[Respond with
304 Not-Modified] - Request --> CalculateEtag + Request --> HasMaxAge + HasMaxAge -->|Yes| CacheValid + HasMaxAge -->|No| FreshTimestamp + CacheValid -->|Yes| UseCached + CacheValid -->|No| FreshTimestamp + UseCached --> CalculateEtag + FreshTimestamp --> CalculateEtag CalculateEtag --> IfNoneMatch IfNoneMatch -->|Yes| EtagMatch IfNoneMatch -->|No| AddETag @@ -119,6 +129,31 @@ Documentation is specific to choice of database: * [EF with PostgreSQL Docs](/docs/postgres-ef.md) when using the [PostgreSQL EF Database Provider](https://www.npgsql.org/efcore) +## Timestamp caching via request Cache-Control + +By default, Delta queries the database on every GET request to retrieve the current timestamp. Clients can opt into timestamp caching by sending `Cache-Control` request headers, allowing Delta to skip the database round-trip when a recent enough cached timestamp exists. + +Supported directives: + + * `max-age=N` — accept a cached timestamp up to N seconds old + * `max-stale=N` — accept a cached timestamp up to N seconds old + * `max-stale` (no value) — accept any cached timestamp regardless of age + +If both `max-age` and `max-stale` are present, the larger (more permissive) value is used. + +Requests without these directives always query the database (existing behavior). + +Example request headers: + +``` +Cache-Control: max-age=5 +``` + +``` +Cache-Control: max-stale +``` + + ## UseResponseDiagnostics Response diagnostics is an opt-out feature that includes extra log information in the response headers. Consider disabling in production to avoid adding diagnostic headers to every response. diff --git a/src/Delta/DeltaExtensions_Shared.cs b/src/Delta/DeltaExtensions_Shared.cs index 8c01574..aa0fc1d 100644 --- a/src/Delta/DeltaExtensions_Shared.cs +++ b/src/Delta/DeltaExtensions_Shared.cs @@ -102,7 +102,18 @@ Ensure authentication middleware runs before UseDelta so that User claims are av """); } - var timeStamp = await getTimeStamp(context); + string timeStamp; + if (TryGetCachedTimeStamp(request, out var cached)) + { + timeStamp = cached; + LogTimeStampCacheHit(logger, level, path); + } + else + { + timeStamp = await getTimeStamp(context); + timeStampCache = new(timeStamp, Stopwatch.GetTimestamp()); + } + var suffixValue = suffix?.Invoke(context); var etag = BuildEtag(timeStamp, suffixValue); response.Headers.ETag = etag; @@ -151,6 +162,108 @@ static void WriteNo304Header(HttpResponse response, string reason, LogLevel leve LogNo304(logger, level, path, reason); } + sealed record TimeStampCache(string Value, long Ticks); + + static TimeStampCache? timeStampCache; + + static bool TryGetCachedTimeStamp(HttpRequest request, [NotNullWhen(true)] out string? timeStamp) + { + timeStamp = null; + var cache = timeStampCache; + if (cache is null) + { + return false; + } + + if (!TryParseStaleness(request.Headers.CacheControl, out var maxSeconds)) + { + return false; + } + + if (maxSeconds != int.MaxValue) + { + var elapsed = Stopwatch.GetElapsedTime(cache.Ticks); + if (elapsed.TotalSeconds > maxSeconds) + { + return false; + } + } + + timeStamp = cache.Value; + return true; + } + + // Parses max-age and max-stale from Cache-Control. + // max-stale without a value means accept any staleness (returns int.MaxValue). + // If both are present, the larger (more permissive) value wins. + static bool TryParseStaleness(StringValues cacheControl, out int maxSeconds) + { + maxSeconds = 0; + var found = false; + + foreach (var value in cacheControl) + { + if (value is null) + { + continue; + } + + if (TryParseDirectiveValue(value, "max-age=", out var maxAge)) + { + found = true; + maxSeconds = Math.Max(maxSeconds, maxAge); + } + + var staleIndex = value.IndexOf("max-stale", StringComparison.OrdinalIgnoreCase); + if (staleIndex >= 0) + { + found = true; + var afterDirective = value.AsSpan(staleIndex + 9); + afterDirective = afterDirective.TrimStart(); + if (afterDirective.Length > 0 && + afterDirective[0] == '=' && + TryParseDirectiveValue(value, "max-stale=", out var maxStale)) + { + maxSeconds = Math.Max(maxSeconds, maxStale); + } + else + { + // max-stale without a value: accept any staleness + maxSeconds = int.MaxValue; + } + } + } + + return found; + } + + static bool TryParseDirectiveValue(string header, string directive, out int seconds) + { + seconds = 0; + var index = header.IndexOf(directive, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return false; + } + + var span = header.AsSpan(index + directive.Length); + var end = 0; + while (end < span.Length && char.IsAsciiDigit(span[end])) + { + end++; + } + + if (end == 0) + { + return false; + } + + return int.TryParse(span[..end], out seconds); + } + + [LoggerMessage(Message = "Delta {path}: Using cached timestamp")] + static partial void LogTimeStampCacheHit(ILogger logger, LogLevel level, string path); + [LoggerMessage(Message = "Delta {path}: ETag {etag}")] static partial void LogEtag(ILogger logger, LogLevel level, string path, string etag); diff --git a/src/Delta/DeltaExtensions_Sql.cs b/src/Delta/DeltaExtensions_Sql.cs index 3ff6519..0e086cc 100644 --- a/src/Delta/DeltaExtensions_Sql.cs +++ b/src/Delta/DeltaExtensions_Sql.cs @@ -145,5 +145,9 @@ static async Task HasViewServerState(DbCommand command, Cancel cancel = de static Task>>? queryTask; - internal static void Reset() => queryTask = null; + internal static void Reset() + { + queryTask = null; + timeStampCache = null; + } } \ No newline at end of file diff --git a/src/DeltaTests/MiddlewareTests.cs b/src/DeltaTests/MiddlewareTests.cs index 8464bd6..763ad69 100644 --- a/src/DeltaTests/MiddlewareTests.cs +++ b/src/DeltaTests/MiddlewareTests.cs @@ -93,6 +93,224 @@ await Verify( .IgnoreMember("Id"); } + [Test] + public async Task MaxAge_UsesCachedTimeStamp() + { + Recording.Start(); + DeltaExtensions.Reset(); + var callCount = 0; + + Task GetTimeStamp(HttpContext _) + { + callCount++; + return Task.FromResult("rowVersion"); + } + + // First request: no max-age, populates the cache + var context1 = new DefaultHttpContext(); + context1.Request.Path = "/path"; + context1.Request.Method = "GET"; + context1.Request.Headers.IfNoneMatch = DeltaExtensions.BuildEtag("rowVersion", null); + + await DeltaExtensions.HandleRequest( + context1, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + AreEqual(1, callCount); + + // Second request: with max-age, should use cached timestamp + var context2 = new DefaultHttpContext(); + context2.Request.Path = "/path"; + context2.Request.Method = "GET"; + context2.Request.Headers.CacheControl = "max-age=10"; + context2.Request.Headers.IfNoneMatch = DeltaExtensions.BuildEtag("rowVersion", null); + + var notModified = await DeltaExtensions.HandleRequest( + context2, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + // DB was NOT called again — cached timestamp used + AreEqual(1, callCount); + IsTrue(notModified); + } + + [Test] + public async Task MaxAge_ExpiredCache_QueriesDb() + { + Recording.Start(); + DeltaExtensions.Reset(); + var callCount = 0; + + Task GetTimeStamp(HttpContext _) + { + callCount++; + return Task.FromResult("rowVersion"); + } + + // First request: populates the cache + var context1 = new DefaultHttpContext(); + context1.Request.Path = "/path"; + context1.Request.Method = "GET"; + + await DeltaExtensions.HandleRequest( + context1, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + AreEqual(1, callCount); + + // Second request: max-age=0 means must be fresh + var context2 = new DefaultHttpContext(); + context2.Request.Path = "/path"; + context2.Request.Method = "GET"; + context2.Request.Headers.CacheControl = "max-age=0"; + + await DeltaExtensions.HandleRequest( + context2, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + // max-age=0 requires fresh data, so DB was called again + AreEqual(2, callCount); + } + + [Test] + public async Task NoMaxAge_AlwaysQueriesDb() + { + Recording.Start(); + DeltaExtensions.Reset(); + var callCount = 0; + + Task GetTimeStamp(HttpContext _) + { + callCount++; + return Task.FromResult("rowVersion"); + } + + // Two requests without max-age + for (var i = 0; i < 2; i++) + { + var context = new DefaultHttpContext(); + context.Request.Path = "/path"; + context.Request.Method = "GET"; + + await DeltaExtensions.HandleRequest( + context, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + } + + // Both requests hit the DB + AreEqual(2, callCount); + } + + [Test] + public async Task MaxStale_UsesCachedTimeStamp() + { + Recording.Start(); + DeltaExtensions.Reset(); + var callCount = 0; + + Task GetTimeStamp(HttpContext _) + { + callCount++; + return Task.FromResult("rowVersion"); + } + + // First request: populates the cache + var context1 = new DefaultHttpContext(); + context1.Request.Path = "/path"; + context1.Request.Method = "GET"; + + await DeltaExtensions.HandleRequest( + context1, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + AreEqual(1, callCount); + + // Second request: max-stale=10, should use cached timestamp + var context2 = new DefaultHttpContext(); + context2.Request.Path = "/path"; + context2.Request.Method = "GET"; + context2.Request.Headers.CacheControl = "max-stale=10"; + + await DeltaExtensions.HandleRequest( + context2, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + AreEqual(1, callCount); + } + + [Test] + public async Task MaxStaleNoValue_UsesCachedTimeStamp() + { + Recording.Start(); + DeltaExtensions.Reset(); + var callCount = 0; + + Task GetTimeStamp(HttpContext _) + { + callCount++; + return Task.FromResult("rowVersion"); + } + + // First request: populates the cache + var context1 = new DefaultHttpContext(); + context1.Request.Path = "/path"; + context1.Request.Method = "GET"; + + await DeltaExtensions.HandleRequest( + context1, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + AreEqual(1, callCount); + + // Second request: max-stale without a value means accept any staleness + var context2 = new DefaultHttpContext(); + context2.Request.Path = "/path"; + context2.Request.Method = "GET"; + context2.Request.Headers.CacheControl = "max-stale"; + + await DeltaExtensions.HandleRequest( + context2, + new RecordingLogger(), + null, + GetTimeStamp, + null, + LogLevel.Information); + + AreEqual(1, callCount); + } + [Test] public void CacheControlExtensions() {