diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml
index d1ef781f..5bd465d5 100644
--- a/.github/workflows/build-test-publish.yml
+++ b/.github/workflows/build-test-publish.yml
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
env:
- RELEASE_VERSION: v3.16.0
+ RELEASE_VERSION: v3.16.1
# Add workflow-level permissions
permissions:
diff --git a/.gitignore b/.gitignore
index 8492fffb..3b90380e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -412,3 +412,5 @@ dist/
**/.DS_Store
.claude/projects
.vscode/settings.json
+
+scrap/
diff --git a/BenchmarkTests/BenchmarkTests.csproj b/BenchmarkTests/BenchmarkTests.csproj
index bcae0082..17e64277 100644
--- a/BenchmarkTests/BenchmarkTests.csproj
+++ b/BenchmarkTests/BenchmarkTests.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj
index 533cf80c..ad48a8a2 100644
--- a/NpgsqlRest/NpgsqlRest.csproj
+++ b/NpgsqlRest/NpgsqlRest.csproj
@@ -29,10 +29,10 @@
trueREADME.MDbin\$(Configuration)\$(AssemblyName).xml
- 3.16.0
- 3.16.0
- 3.16.0
- 3.16.0
+ 3.16.1
+ 3.16.1
+ 3.16.1
+ 3.16.1true
@@ -42,7 +42,7 @@
-
+
diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs
index 9f70e955..cd90cd63 100644
--- a/NpgsqlRest/NpgsqlRestEndpoint.cs
+++ b/NpgsqlRest/NpgsqlRestEndpoint.cs
@@ -1,4 +1,5 @@
using System.Buffers;
+using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Data;
using System.IO.Pipelines;
@@ -23,6 +24,36 @@ public partial class NpgsqlRestEndpoint(
NpgsqlRestMetadataEntry entry,
FrozenDictionary overloads)
{
+ // Signals a non-value exit from inside a cache GetOrCreateAsync factory, so the result is never
+ // cached and concurrent waiters observe the same outcome. NoRow → the command returned no row
+ // (caller maps to 500); ResponseAlreadyWritten → PrepareCommand short-circuited via
+ // CommandCallbackAsync and already wrote the lead caller's response (caller just returns).
+ private enum CacheFactoryAbortReason { NoRow, ResponseAlreadyWritten }
+
+ private sealed class CacheFactoryAbortException(CacheFactoryAbortReason reason) : Exception
+ {
+ public CacheFactoryAbortReason Reason { get; } = reason;
+ }
+
+ // Per-cache-key execution gates for the streaming records/sets path. That path cannot use the
+ // value-shaped GetOrCreateAsync factory model because it streams rows to the client and disables
+ // caching mid-stream once a response exceeds MaxCacheableRows. The gate serializes concurrent
+ // requests for the same key: the lead executes and (when within limits) populates the cache, so
+ // the rest get a cache hit instead of re-executing; over-limit responses simply run one-at-a-time,
+ // which still caps concurrent DB executions per key at one.
+ //
+ // The outer map is keyed by the cache instance itself (reference identity, so distinct cache
+ // profiles never share a gate even if they reuse a key string) and an inner map holds one gate per
+ // key. SemaphoreSlim instances are never disposed — they hold no OS handle unless AvailableWaitHandle
+ // is touched, and their count is bounded by (cache instances × distinct keys), bounded by the schema.
+ private static readonly ConcurrentDictionary> _recordCacheGates
+ = new(ReferenceEqualityComparer.Instance);
+
+ private static SemaphoreSlim GetRecordCacheGate(IRoutineCache cache, string key) =>
+ _recordCacheGates
+ .GetOrAdd(cache, static _ => new(StringComparer.Ordinal))
+ .GetOrAdd(key, static _ => new SemaphoreSlim(1, 1));
+
public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
{
Routine routine = entry.Endpoint.Routine;
@@ -40,6 +71,8 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi
string? commandText = null;
bool shouldDispose = true;
bool shouldCommit = true;
+ // Held (when non-null) for the streaming records/sets cache path; released in the outer finally.
+ SemaphoreSlim? recordCacheGate = null;
IUploadHandler? uploadHandler = null;
var shouldLog = Options.LogCommands && Logger != null;
@@ -1721,28 +1754,9 @@ await Results.Problem(
}
}
- // Check cache for passthrough proxy endpoints (those without proxy response parameters)
+ // Passthrough proxy endpoints (those without proxy response parameters) may be cached;
+ // the cache read/execute/coalesce is handled at the InvokeAsync call below.
bool isPassthroughProxy = !endpoint.HasProxyResponseParameters;
- if (isPassthroughProxy &&
- endpoint.Cached is true &&
- bypassCache is false &&
- resolvedCache is not null)
- {
- if (resolvedCache.Get(endpoint, cacheKeyString!, out var cachedProxyResponse))
- {
- // Cache hit - return cached proxy response
- if (cachedProxyResponse is Proxy.ProxyResponse cached)
- {
- if (shouldLog)
- {
- cmdLog?.AppendLine("/* proxy response from cache */");
- NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", commandText);
- }
- await Proxy.ProxyRequestHandler.WriteResponseAsync(context, cached, Options.ProxyOptions, cancellationToken);
- return;
- }
- }
- }
// Build user context headers if UserContext is enabled
Dictionary? userContextHeaders = null;
@@ -1785,6 +1799,36 @@ bypassCache is false &&
}
}
+ // Cacheable passthrough proxy: route the upstream call through GetOrCreateAsync so a burst
+ // of identical requests coalesces into a single upstream call (stampede protection), and
+ // the response is cached for subsequent hits.
+ bool proxyCacheable = isPassthroughProxy && endpoint.Cached is true && bypassCache is false && resolvedCache is not null;
+ if (proxyCacheable)
+ {
+ bool factoryRan = false;
+ var cachedProxy = await resolvedCache!.GetOrCreateAsync(
+ endpoint,
+ cacheKeyString!,
+ async ct =>
+ {
+ factoryRan = true;
+ return await Proxy.ProxyRequestHandler.InvokeAsync(context, endpoint, body, command.Parameters, userContextHeaders, ct);
+ },
+ cacheTtlOverride,
+ cancellationToken);
+
+ if (shouldLog && factoryRan is false)
+ {
+ cmdLog?.AppendLine("/* proxy response from cache */");
+ NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", commandText);
+ }
+ if (cachedProxy is Proxy.ProxyResponse cachedProxyResponse)
+ {
+ await Proxy.ProxyRequestHandler.WriteResponseAsync(context, cachedProxyResponse, Options.ProxyOptions, cancellationToken);
+ }
+ return;
+ }
+
var proxyResponse = await Proxy.ProxyRequestHandler.InvokeAsync(context, endpoint, body, command.Parameters, userContextHeaders, cancellationToken);
if (endpoint.HasProxyResponseParameters)
@@ -1794,12 +1838,7 @@ bypassCache is false &&
}
else
{
- // No proxy parameters - return proxy response directly without invoking PostgreSQL
- // Cache the proxy response if caching is enabled
- if (endpoint.Cached is true && bypassCache is false && resolvedCache is not null)
- {
- resolvedCache.AddOrUpdate(endpoint, cacheKeyString!, proxyResponse, cacheTtlOverride);
- }
+ // No proxy parameters and not cacheable - return proxy response directly without PostgreSQL.
await Proxy.ProxyRequestHandler.WriteResponseAsync(context, proxyResponse, Options.ProxyOptions, cancellationToken);
return;
}
@@ -2414,41 +2453,55 @@ await command.ExecuteNonQueryWithRetryAsync(
object? valueResult;
if (resolvedCache is not null && endpoint.Cached is true && bypassCache is false)
{
- if (resolvedCache.Get(endpoint, cacheKeyString!, out valueResult) is false)
+ // Route through GetOrCreateAsync so a burst of identical requests coalesces into a
+ // single connection-open + execution (stampede protection). The connection is opened
+ // by PrepareCommand *inside* the factory, so coalesced waiters never touch the DB.
+ bool factoryRan = false;
+ try
{
- if (await PrepareCommand(connection, command, commandText, context, endpoint, true, cancellationToken) is false)
- {
- return;
- }
-
- await using var reader = await command.ExecuteReaderWithRetryAsync(
- CommandBehavior.SequentialAccess,
- endpoint.RetryStrategy,
- cancellationToken,
- errorCodePolicy: endpoint.ErrorCodePolicy ?? Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
- if (shouldLog)
- {
- NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", command.CommandText);
- }
- if (await reader.ReadAsync(cancellationToken))
- {
- valueResult = descriptor.IsBinary ? reader.GetFieldValue(0) : reader.GetValue(0) as string;
- resolvedCache.AddOrUpdate(endpoint, cacheKeyString!, valueResult, cacheTtlOverride);
- }
- else
+ valueResult = await resolvedCache.GetOrCreateAsync(
+ endpoint,
+ cacheKeyString!,
+ async ct =>
+ {
+ factoryRan = true;
+ if (await PrepareCommand(connection, command, commandText, context, endpoint, true, ct) is false)
+ {
+ throw new CacheFactoryAbortException(CacheFactoryAbortReason.ResponseAlreadyWritten);
+ }
+
+ await using var reader = await command.ExecuteReaderWithRetryAsync(
+ CommandBehavior.SequentialAccess,
+ endpoint.RetryStrategy,
+ ct,
+ errorCodePolicy: endpoint.ErrorCodePolicy ?? Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
+ if (shouldLog)
+ {
+ NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", command.CommandText);
+ }
+ if (await reader.ReadAsync(ct))
+ {
+ return descriptor.IsBinary ? reader.GetFieldValue(0) : reader.GetValue(0) as string;
+ }
+ throw new CacheFactoryAbortException(CacheFactoryAbortReason.NoRow);
+ },
+ cacheTtlOverride,
+ cancellationToken);
+ }
+ catch (CacheFactoryAbortException abort)
+ {
+ if (abort.Reason == CacheFactoryAbortReason.NoRow)
{
Logger?.CouldNotReadCommand(command.CommandText, context.Request.Method, context.Request.Path);
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
- return;
}
+ // ResponseAlreadyWritten: PrepareCommand already wrote the lead caller's response.
+ return;
}
- else
+ if (factoryRan is false && shouldLog)
{
- if (shouldLog)
- {
- cmdLog?.AppendLine("/* from cache */");
- NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", commandText);
- }
+ cmdLog?.AppendLine("/* from cache */");
+ NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", commandText);
}
}
else
@@ -2566,6 +2619,13 @@ Options.AuthenticationOptions.DefaultDataProtector is not null &&
if (canCacheRecordsAndSets)
{
+ // Stampede protection for the streaming set path: serialize concurrent requests
+ // for this key. The lead executes and (within MaxCacheableRows) populates the cache
+ // below, so the waiters that acquire the gate next fall straight into this cache-hit
+ // branch instead of re-executing. Released in the outer finally.
+ recordCacheGate = GetRecordCacheGate(resolvedCache!, cacheKeyString!);
+ await recordCacheGate.WaitAsync(cancellationToken);
+
if (resolvedCache!.Get(endpoint, cacheKeyString!, out var cachedResult))
{
if (shouldLog)
@@ -3227,6 +3287,10 @@ await Results.Problem(
}
finally
{
+ // Release the records/sets stampede gate (if this request acquired it) so the next waiter
+ // for this key can proceed — by now the cache is either populated or the response was over-limit.
+ recordCacheGate?.Release();
+
await writer.CompleteAsync();
// proxy_out: capture buffered function output and forward to upstream proxy
diff --git a/NpgsqlRest/RoutineCache.cs b/NpgsqlRest/RoutineCache.cs
index 674e4a3b..9eec91f1 100644
--- a/NpgsqlRest/RoutineCache.cs
+++ b/NpgsqlRest/RoutineCache.cs
@@ -14,6 +14,39 @@ public interface IRoutineCache
///
void AddOrUpdate(RoutineEndpoint endpoint, string key, object? value, TimeSpan? overrideExpiration = null);
bool Remove(string key);
+
+ ///
+ /// Atomic get-or-compute. On a cache hit the cached value is returned and
+ /// is NOT invoked. On a miss the factory runs the underlying work (e.g. opening the connection,
+ /// executing SQL and serializing the response) and its result is stored before returning.
+ ///
+ /// Backends that override this provide stampede protection: a burst of concurrent calls with the
+ /// same coalesce into a single factory invocation; the remaining callers
+ /// await the in-flight result instead of each hitting the database. The default implementation
+ /// below performs a plain probe-then-compute with no coalescing, so pre-existing custom
+ /// implementations keep working unchanged (they simply gain no stampede protection).
+ ///
+ ///
+ /// The endpoint whose response is being cached (carries the default TTL).
+ /// The cache key; identical keys coalesce into one factory invocation.
+ /// Runs the underlying work when the cache is cold. Should respect the token.
+ /// When non-null, the per-request TTL to use instead of the endpoint default.
+ /// Cancels this caller's wait; see backend notes for coalescing semantics.
+ async ValueTask