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()
{