Skip to content

Commit 491d399

Browse files
committed
cacing system improvements
1 parent 4ff1544 commit 491d399

11 files changed

Lines changed: 954 additions & 32 deletions

File tree

NpgsqlRest/NpgsqlRestEndpoint.cs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
297297
{
298298
if (endpoint.CachedParams.Contains(parameter.ConvertedName) || endpoint.CachedParams.Contains(parameter.ActualName))
299299
{
300+
cacheKeys?.Append(NpgsqlRestParameter.GetCacheKeySeparator());
300301
cacheKeys?.Append(parameter.GetCacheStringValue());
301302
}
302303
}
@@ -384,6 +385,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
384385
{
385386
if (endpoint.CachedParams.Contains(parameter.ConvertedName) || endpoint.CachedParams.Contains(parameter.ActualName))
386387
{
388+
cacheKeys?.Append(NpgsqlRestParameter.GetCacheKeySeparator());
387389
cacheKeys?.Append(parameter.GetCacheStringValue());
388390
}
389391
}
@@ -529,6 +531,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
529531
{
530532
if (endpoint.CachedParams.Contains(parameter.ConvertedName) || endpoint.CachedParams.Contains(parameter.ActualName))
531533
{
534+
cacheKeys?.Append(NpgsqlRestParameter.GetCacheKeySeparator());
532535
cacheKeys?.Append(parameter.GetCacheStringValue());
533536
}
534537
}
@@ -675,6 +678,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
675678
{
676679
if (endpoint.CachedParams.Contains(parameter.ConvertedName) || endpoint.CachedParams.Contains(parameter.ActualName))
677680
{
681+
cacheKeys?.Append(NpgsqlRestParameter.GetCacheKeySeparator());
678682
cacheKeys?.Append(parameter.GetCacheStringValue());
679683
}
680684
}
@@ -849,6 +853,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
849853
{
850854
if (endpoint.CachedParams.Contains(parameter.ConvertedName) || endpoint.CachedParams.Contains(parameter.ActualName))
851855
{
856+
cacheKeys?.Append(NpgsqlRestParameter.GetCacheKeySeparator());
852857
cacheKeys?.Append(parameter.GetCacheStringValue());
853858
}
854859
}
@@ -993,6 +998,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues(
993998
{
994999
if (endpoint.CachedParams.Contains(parameter.ConvertedName) || endpoint.CachedParams.Contains(parameter.ActualName))
9951000
{
1001+
cacheKeys?.Append(NpgsqlRestParameter.GetCacheKeySeparator());
9961002
cacheKeys?.Append(parameter.GetCacheStringValue());
9971003
}
9981004
}
@@ -1506,11 +1512,39 @@ await command.ExecuteNonQueryWithRetryAsync(
15061512
}
15071513
else // end if (routine.ReturnsRecord == false)
15081514
{
1515+
var binary = routine.ColumnsTypeDescriptor.Length == 1 && routine.ColumnsTypeDescriptor[0].IsBinary;
1516+
1517+
// Check cache for records/sets (but not for binary or raw mode)
1518+
var canCacheRecordsAndSets = Options.CacheOptions.DefaultRoutineCache is not null
1519+
&& endpoint.Cached is true
1520+
&& binary is false
1521+
&& endpoint.Raw is false;
1522+
1523+
if (canCacheRecordsAndSets)
1524+
{
1525+
if (Options.CacheOptions.DefaultRoutineCache!.Get(endpoint, cacheKeys?.ToString()!, out var cachedResult))
1526+
{
1527+
if (shouldLog)
1528+
{
1529+
cmdLog?.AppendLine("/* from cache */");
1530+
NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", commandText ?? "");
1531+
}
1532+
1533+
if (context.Response.ContentType is null)
1534+
{
1535+
context.Response.ContentType = Application.Json;
1536+
}
1537+
1538+
var cachedSpan = (cachedResult as string ?? "").AsSpan();
1539+
writer.Advance(Encoding.UTF8.GetBytes(cachedSpan, writer.GetSpan(Encoding.UTF8.GetMaxByteCount(cachedSpan.Length))));
1540+
return;
1541+
}
1542+
}
1543+
15091544
if (await PrepareCommand(connection, command, commandText, context, endpoint, true) is false)
15101545
{
15111546
return;
15121547
}
1513-
var binary = routine.ColumnsTypeDescriptor.Length == 1 && routine.ColumnsTypeDescriptor[0].IsBinary;
15141548
await using var reader = await command.ExecuteReaderWithRetryAsync(
15151549
CommandBehavior.SequentialAccess,
15161550
endpoint.RetryStrategy,
@@ -1532,9 +1566,15 @@ await command.ExecuteNonQueryWithRetryAsync(
15321566
}
15331567
}
15341568

1569+
// For caching, we need to buffer the entire response
1570+
StringBuilder? cacheBuffer = canCacheRecordsAndSets ? new() : null;
1571+
var maxCacheableRows = Options.CacheOptions.MaxCacheableRows;
1572+
var shouldCache = canCacheRecordsAndSets;
1573+
15351574
if (routine.ReturnsSet && endpoint.Raw is false && binary is false)
15361575
{
15371576
writer.Advance(Encoding.UTF8.GetBytes(Consts.OpenBracket.ToString().AsSpan(), writer.GetSpan(Encoding.UTF8.GetMaxByteCount(1))));
1577+
cacheBuffer?.Append(Consts.OpenBracket);
15381578
}
15391579

15401580
bool first = true;
@@ -1700,8 +1740,20 @@ await command.ExecuteNonQueryWithRetryAsync(
17001740
}
17011741
} // end for
17021742

1743+
// Check if we've exceeded the cacheable row limit
1744+
if (shouldCache && maxCacheableRows.HasValue && rowCount > (ulong)maxCacheableRows.Value)
1745+
{
1746+
shouldCache = false;
1747+
cacheBuffer = null; // Release memory
1748+
}
1749+
17031750
if (bufferRows != 1 && rowCount % bufferRows == 0)
17041751
{
1752+
// Append to cache buffer before clearing row
1753+
if (shouldCache)
1754+
{
1755+
cacheBuffer?.Append(row);
1756+
}
17051757
WriteStringBuilderToWriter(row, writer);
17061758
await writer.FlushAsync();
17071759
row.Clear();
@@ -1716,12 +1768,24 @@ await command.ExecuteNonQueryWithRetryAsync(
17161768
{
17171769
if (row.Length > 0)
17181770
{
1771+
// Append remaining rows to cache buffer
1772+
if (shouldCache)
1773+
{
1774+
cacheBuffer?.Append(row);
1775+
}
17191776
WriteStringBuilderToWriter(row, writer);
17201777
await writer.FlushAsync();
17211778
}
17221779
if (routine.ReturnsSet && endpoint.Raw is false)
17231780
{
17241781
writer.Advance(Encoding.UTF8.GetBytes(Consts.CloseBracket.ToString().AsSpan(), writer.GetSpan(Encoding.UTF8.GetMaxByteCount(1))));
1782+
cacheBuffer?.Append(Consts.CloseBracket);
1783+
}
1784+
1785+
// Store in cache if within limits
1786+
if (shouldCache && cacheBuffer is not null)
1787+
{
1788+
Options.CacheOptions.DefaultRoutineCache?.AddOrUpdate(endpoint, cacheKeys?.ToString()!, cacheBuffer.ToString());
17251789
}
17261790
}
17271791
return;

NpgsqlRest/NpgsqlRestParameter.cs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Text.Json.Nodes;
1+
using System.Text;
2+
using System.Text.Json.Nodes;
23
using Microsoft.Extensions.Primitives;
34
using Npgsql;
45

@@ -64,19 +65,41 @@ public NpgsqlRestParameter(
6465
}
6566
}
6667

68+
private const char CacheKeySeparator = '\x1F'; // Unit Separator - non-printable ASCII character
69+
private const string CacheKeyNull = "\x00NULL\x00"; // Distinct marker for null/DBNull values
70+
6771
internal string GetCacheStringValue()
6872
{
69-
if (Value == DBNull.Value)
73+
if (Value is null || Value == DBNull.Value)
7074
{
71-
return string.Empty;
75+
return CacheKeyNull;
7276
}
7377
if (TypeDescriptor.IsArray)
7478
{
75-
return string.Join(",", Value as object[] ?? []);
79+
// Arrays can be stored as List<object?> (from query string parsing) or object[]
80+
IList<object?>? list = Value as IList<object?>;
81+
if (list is null || list.Count == 0)
82+
{
83+
return "[]";
84+
}
85+
var sb = new StringBuilder();
86+
sb.Append('[');
87+
for (int i = 0; i < list.Count; i++)
88+
{
89+
if (i > 0)
90+
{
91+
sb.Append(CacheKeySeparator);
92+
}
93+
sb.Append(list[i]?.ToString() ?? CacheKeyNull);
94+
}
95+
sb.Append(']');
96+
return sb.ToString();
7697
}
77-
return Value?.ToString() ?? string.Empty;
98+
return Value.ToString() ?? CacheKeyNull;
7899
}
79100

101+
internal static char GetCacheKeySeparator() => CacheKeySeparator;
102+
80103
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
81104
#pragma warning disable CS8603 // Possible null reference return.
82105
public NpgsqlRestParameter NpgsqlRestParameterMemberwiseClone() => MemberwiseClone() as NpgsqlRestParameter;

NpgsqlRest/Options/CacheOptions.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@ public class CacheOptions
66
/// Default routine cache object. Inject custom cache object to override default cache. Set to null to disable caching.
77
/// </summary>
88
public IRoutineCache? DefaultRoutineCache { get; set; } = new RoutineCache();
9-
9+
1010
/// <summary>
1111
/// When cache is enabled, this value sets the interval in minutes for cache pruning (removing expired entries). Default is 1 minute.
1212
/// </summary>
1313
public int MemoryCachePruneIntervalSeconds { get; set; } = 60;
14+
15+
/// <summary>
16+
/// Maximum number of rows that can be cached for set-returning functions.
17+
/// If a result set exceeds this limit, it will not be cached (but will still be returned).
18+
/// Set to 0 to disable caching for sets entirely. Set to null for unlimited (use with caution).
19+
/// Default is 1000 rows.
20+
/// </summary>
21+
public int? MaxCacheableRows { get; set; } = 1000;
1422
}

NpgsqlRest/RoutineCache.cs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ private class CacheEntry
1717
public bool IsExpired => ExpirationTime.HasValue && DateTime.UtcNow > ExpirationTime.Value;
1818
}
1919

20-
private static readonly ConcurrentDictionary<int, CacheEntry> _cache = new();
21-
private static readonly ConcurrentDictionary<int, string> _originalKeys = new();
20+
private static readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
2221
private static Timer? _cleanupTimer;
2322

2423
public static void Start(NpgsqlRestOptions options)
@@ -34,7 +33,6 @@ public static void Shutdown()
3433
{
3534
_cleanupTimer?.Dispose();
3635
_cache.Clear();
37-
_originalKeys.Clear();
3836
}
3937

4038
private static void CleanupExpiredEntriesInternal()
@@ -47,30 +45,22 @@ private static void CleanupExpiredEntriesInternal()
4745
foreach (var key in expiredKeys)
4846
{
4947
_cache.TryRemove(key, out _);
50-
_originalKeys.TryRemove(key, out _);
5148
}
5249
}
5350

5451
public bool Get(RoutineEndpoint endpoint, string key, out object? result)
5552
{
56-
var hashedKey = key.GetHashCode();
57-
58-
if (_cache.TryGetValue(hashedKey, out var entry))
53+
if (_cache.TryGetValue(key, out var entry))
5954
{
60-
if (_originalKeys.TryGetValue(hashedKey, out var originalKey) && originalKey == key)
55+
if (entry.IsExpired)
6156
{
62-
if (entry.IsExpired)
63-
{
64-
// Remove expired entry
65-
_cache.TryRemove(hashedKey, out _);
66-
_originalKeys.TryRemove(hashedKey, out _);
67-
result = null;
68-
return false;
69-
}
70-
71-
result = entry.Value;
72-
return true;
57+
_cache.TryRemove(key, out _);
58+
result = null;
59+
return false;
7360
}
61+
62+
result = entry.Value;
63+
return true;
7464
}
7565

7666
result = null;
@@ -79,14 +69,12 @@ public bool Get(RoutineEndpoint endpoint, string key, out object? result)
7969

8070
public void AddOrUpdate(RoutineEndpoint endpoint, string key, object? value)
8171
{
82-
var hashedKey = key.GetHashCode();
8372
var entry = new CacheEntry
8473
{
8574
Value = value,
8675
ExpirationTime = endpoint.CacheExpiresIn.HasValue ? DateTime.UtcNow + endpoint.CacheExpiresIn.Value : null
8776
};
8877

89-
_cache[hashedKey] = entry;
90-
_originalKeys[hashedKey] = key;
78+
_cache[key] = entry;
9179
}
9280
}

NpgsqlRestClient/Builder.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -996,12 +996,14 @@ public CacheOptions BuildCacheOptions(WebApplication app)
996996
}
997997

998998
var type = _config.GetConfigEnum<CacheType?>("Type", cacheCfg) ?? CacheType.Memory;
999+
options.MaxCacheableRows = _config.GetConfigInt("MaxCacheableRows", cacheCfg);
9991000
if (type == CacheType.Memory)
10001001
{
10011002
options.MemoryCachePruneIntervalSeconds =
10021003
_config.GetConfigInt("MemoryCachePruneIntervalSeconds", cacheCfg) ?? 60;
10031004
options.DefaultRoutineCache = new RoutineCache();
1004-
Logger?.LogDebug("Using in-memory routine cache with prune interval of {MemoryCachePruneIntervalSeconds} seconds.", options.MemoryCachePruneIntervalSeconds);
1005+
Logger?.LogDebug("Using in-memory routine cache with prune interval of {MemoryCachePruneIntervalSeconds} seconds. MaxCacheableRows={MaxCacheableRows}",
1006+
options.MemoryCachePruneIntervalSeconds, options.MaxCacheableRows);
10051007
}
10061008
else if (type == CacheType.Redis)
10071009
{
@@ -1012,7 +1014,8 @@ public CacheOptions BuildCacheOptions(WebApplication app)
10121014
{
10131015
var redisCache = new RedisCache(configuration, Logger);
10141016
options.DefaultRoutineCache = redisCache;
1015-
Logger?.LogDebug("Using Redis routine cache with configuration: {RedisConfiguration}", configuration);
1017+
Logger?.LogDebug("Using Redis routine cache with configuration: {RedisConfiguration}. MaxCacheableRows={MaxCacheableRows}",
1018+
configuration, options.MaxCacheableRows);
10161019
app.Lifetime.ApplicationStopping.Register(() => redisCache.Dispose());
10171020
}
10181021
catch (Exception ex)

NpgsqlRestClient/appsettings.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,13 @@
748748
// Redis configuration string. Only used when CacheType is set to Redis.
749749
// See: https://stackexchange.github.io/StackExchange.Redis/Configuration.html
750750
//
751-
"RedisConfiguration": "localhost:6379,abortConnect=false,ssl=false,connectTimeout=10000,syncTimeout=5000,connectRetry=3"
751+
"RedisConfiguration": "localhost:6379,abortConnect=false,ssl=false,connectTimeout=10000,syncTimeout=5000,connectRetry=3",
752+
//
753+
// Maximum number of rows that can be cached for set-returning functions.
754+
// If a result set exceeds this limit, it will not be cached (but will still be returned).
755+
// Set to 0 to disable caching for sets entirely. Set to null for unlimited (use with caution).
756+
//
757+
"MaxCacheableRows": 1000
752758
},
753759

754760
//

0 commit comments

Comments
 (0)