CL.PostgreSQL — Performance & Caching
Result cache
Opt in per query:
var rows = await pg.Query<User>()
.Where(u => u.IsActive)
.WithCache(TimeSpan.FromMinutes(5))
.ToListAsync();
WithCache() with no argument uses postgresql.cache.defaultTtlSeconds (60 by default).
The same overload exists on a projected query, so .Select(…).WithCache() works too.
Configuration
Setting (postgresql.cache) |
Default | Effect |
|---|---|---|
enabled |
true |
Master switch. A per-database cacheEnabledOverride wins over it for that connection. |
maxEntries |
10000 |
Entry-count cap for the in-process store. |
defaultTtlSeconds |
60 |
Lifetime used by the parameterless WithCache(). |
timeQuantizeSeconds |
60 |
Rounding window for DateTime parameters in cache keys. 0 disables. |
publishEvents |
true |
Whether CacheHitEvent / CacheMissEvent are published. Set false to keep the cache but silence its per-query events. |
maxMemoryMb |
256 |
Obsolete — the store evicts by entry count, not bytes. Use maxEntries. |
Not cacheable
Three cases are refused rather than cached wrongly, because the cache stamps an entry with a single table's version counter:
- reads inside a transaction scope (they see uncommitted writes);
- queries carrying a subquery filter (
WhereExists/WhereIn) — the inner table's mutations could not invalidate the entry. The refusal follows the query into.Select(…)and.GroupBy(…), so.WhereExists(…).Select(…).WithCache(…)is uncached too (and logs a warning) rather than serving a stale cross-table result; - typed joins, which have no
.WithCacheat all.
Cache keys
A key is the SHA-256 of the connection id, table name, the table's current version, the SQL text, and the parameters sorted by name. Including the connection id keeps tenants on separate connections from sharing entries.
byte[] parameter values are hex-encoded rather than ToString()-ed — otherwise every
binary value would stringify to System.Byte[] and collapse into one key.
Time quantization
Where(x => x.At >= DateTime.UtcNow.AddDays(-30)) produces a different parameter value on
every call, so it would never hit cache. DateTime parameters within a year of now are
rounded down to a timeQuantizeSeconds bucket (default 60) before hashing, making the key
stable across back-to-back calls. Far-past and far-future absolute dates pass through
unrounded.
Table-version invalidation
Every table carries a version counter that participates in the key. Any write through the library bumps it, which makes every prior entry for that table unreachable at once — no scanning, no per-key eviction. Stale entries are swept later by TTL.
QueryCache.Invalidate<User>(); // or Invalidate("users")
Failure results are never cached
A Result<T> in a failed state is not written, and a cached value that is detected as a
failure is evicted on read. A transient database error during a cold warm-up therefore
cannot poison the cache for the whole TTL.
Stampede protection
Concurrent misses on the same cold key collapse into a single execution. The first caller creates the in-flight task and the rest await it, so a popular key expiring does not produce a thundering herd.
Smart cache pools
A pool is a named, pre-warmed entry that refreshes itself in the background rather than expiring and forcing a caller to wait.
// The warm-up callback is a Func<Task>; queries register themselves with the pool
// through their own .SmartCache(name) decoration.
pg.RegisterCachePool("active-users", TimeSpan.FromMinutes(1), maxIdleFires: 10,
warmUp: () => pg.Query<User>().Where(u => u.IsActive).SmartCache("active-users").ToListAsync());
var rows = await pg.Query<User>().SmartCache("active-users").ToListAsync();
An individual entry is dropped from the pool after MaxIdleFires consecutive refresh ticks
with no read (default 10), so an unread parameterisation stops being refreshed; the pool's
timer itself keeps running. GetCachePoolStats() reports the refresh interval, MaxIdleFires,
the live entry count, ticks fired and failed, and the last tick time.
Multi-node
The default store is in-process. For several nodes, supply a shared store and a coordinator:
QueryCache.UseStore(new RedisCacheStore(...));
QueryCache.UseCoordinator(new RedisCacheCoordinator(...));
Both are needed. The store shares entries; the coordinator broadcasts invalidations so a write on one node bumps the table version everywhere. A shared store without a coordinator is the dangerous combination: each node's version counter advances independently, so node B keeps serving entries that node A's write should have invalidated.
The coordinator also single-flights pool refreshes across nodes via a refresh lease, so one expiring pool does not trigger the same query on every node at once.
Compiled materializers
Rows are mapped by an expression-tree materializer compiled once per type and specialised to
the reader's column ordinals, not by per-row reflection. Property accessors on
EntityMetadata<T> are compiled getters and setters for the same reason.
Projections go further: Select(u => new { u.Id, u.Email }) narrows the generated SELECT
to those columns and compiles a mapper for the projection shape, so unselected columns are
never transferred or materialized.
Transient retry
Serialization failures and deadlocks (SQLSTATE 40001 and 40P01) mean the transaction was
rolled back cleanly and re-running it may succeed. Those are retried
transientRetryCount times with exponential backoff and jitter. Lock-not-available (55P03)
is treated the same way.
Other errors are not retried: a constraint violation or a syntax error will fail identically on a second attempt.
Batch limits
PostgreSQL's extended protocol caps a statement at 65535 bound parameters. Batched inserts
and upserts chunk at min(batchSize, (65535 - 16) / columnsPerRow), so a wide table
automatically gets smaller batches rather than failing at the wire. batchSize comes from
the connection's maxBatchInsertSize (default 500).
Generated IN lists are not chunked: ids.Contains(x.Id) emits one bound parameter per
value in a single list. maxInClauseValues (default 1000) is advisory — exceeding it logs a
warning once per query build, naming the entity and the value count, and the list is still
sent whole. Chunk large sets yourself if the plan degrades.
Observability
Slow queries
Queries at or over slowQueryThresholdMs are logged as a warning and raise a
SlowQueryEvent carrying the connection id, SQL text and elapsed milliseconds.
Set captureExplainOnSlowQuery (default false) to have the plan attached as
SlowQueryEvent.ExplainJson. The library then runs EXPLAIN (FORMAT JSON) <sql> with the
same bound parameters on a separate connection. It is strictly best-effort:
- the fetch happens off the query path, so it never delays the caller;
- it is skipped entirely for a query running inside a transaction scope, so it can never interleave with the caller's transaction;
- statements
EXPLAINcannot accept (DDL, utility commands, multi-statement batches) and parameterized statements whose values were not captured are skipped; - any failure is swallowed and the event still publishes, with a null payload.
EXPLAIN without ANALYZE only plans the statement — it does not execute it.
N+1 detection
Set n1DetectorThreshold above 0 to have the library count executions of the same
normalized SQL template per connection over a rolling one-second window. When the count
reaches the threshold, N1QueryDetectedEvent is published once for that window; further
executions inside it stay silent and the next window starts fresh.
{ "databases": { "Default": { "n1DetectorThreshold": 20 } } }
Normalization folds digit runs to ? and collapses whitespace, so WHERE id = @qb_0 and
WHERE id = @qb_7 are the same template. Cache hits are not counted — they never reached
the server.
0 is the default and disables the detector completely: no allocation, no dictionary
lookup, one bool read per query. The bookkeeping is capped at 512 templates and pruned by
age, so it cannot grow without bound on a long-running process.
Counters
var stats = pg.GetCacheStats(); // TotalEntries, EntriesByTable, TableVersions
var pools = pg.GetCachePoolStats(); // per-pool refresh state
GetCacheStats() is a structural snapshot — how many entries exist and per table, plus the
table-version counters. It does not track hit/miss or eviction counts; subscribe to
CacheHitEvent / CacheMissEvent for those.
QueryExecutedEvent fires after every query with its SQL, elapsed milliseconds, row count
and cache-hit flag, which is usually the easiest hook for metrics or tracing.
Indexing notes
[Index(Include = [...])]emits a realINCLUDEclause, giving an index-only scan without widening the key.[Column(Unique = true)]and unique[CompositeIndex]create constraints, not bare indexes, soON CONFLICTcan arbitrate on them. A plain unique index cannot be a conflict target by name.- Identifiers are quoted and therefore case-sensitive; an index created outside the library
on
userIdwill not be matched against a model column nameduserid.