Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,22 @@ Frequency of updates to data is relatively low compared to reads
```mermaid
graph TD
Request
HasMaxAge{Has max-age<br/>or max-stale<br/>in Cache-Control?}
CacheValid{Cached timestamp<br/>within allowed<br/>staleness?}
FreshTimestamp[Get fresh timestamp<br/>from SQL]
UseCached[Use cached timestamp]
CalculateEtag[Calculate current ETag<br/>based on timestamp<br/>from web assembly and SQL]
IfNoneMatch{Has<br/>If-None-Match<br/>header?}
EtagMatch{Current<br/>Etag matches<br/>If-None-Match?}
AddETag[Add current ETag<br/>to Response headers]
304[Respond with<br/>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
Expand Down Expand Up @@ -135,7 +145,7 @@ internal static string BuildEtag(string timeStamp, string? suffix)
return $"\"{AssemblyWriteTime}-{timeStamp}-{suffix}\"";
}
```
<sup><a href='/src/Delta/DeltaExtensions_Shared.cs#L172-L184' title='Snippet source file'>snippet source</a> | <a href='#snippet-BuildEtag' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Delta/DeltaExtensions_Shared.cs#L285-L297' title='Snippet source file'>snippet source</a> | <a href='#snippet-BuildEtag' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -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.
Expand Down
37 changes: 36 additions & 1 deletion readme.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,22 @@ Frequency of updates to data is relatively low compared to reads
```mermaid
graph TD
Request
HasMaxAge{Has max-age<br/>or max-stale<br/>in Cache-Control?}
CacheValid{Cached timestamp<br/>within allowed<br/>staleness?}
FreshTimestamp[Get fresh timestamp<br/>from SQL]
UseCached[Use cached timestamp]
CalculateEtag[Calculate current ETag<br/>based on timestamp<br/>from web assembly and SQL]
IfNoneMatch{Has<br/>If-None-Match<br/>header?}
EtagMatch{Current<br/>Etag matches<br/>If-None-Match?}
AddETag[Add current ETag<br/>to Response headers]
304[Respond with<br/>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
Expand Down Expand Up @@ -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.
Expand Down
115 changes: 114 additions & 1 deletion src/Delta/DeltaExtensions_Shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
6 changes: 5 additions & 1 deletion src/Delta/DeltaExtensions_Sql.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,9 @@ static async Task<bool> HasViewServerState(DbCommand command, Cancel cancel = de

static Task<Func<DbCommand, Cancel, Task<string>>>? queryTask;

internal static void Reset() => queryTask = null;
internal static void Reset()
{
queryTask = null;
timeStampCache = null;
}
}
Loading
Loading