Optimize CloseInactiveSessionsJob: batch Redis calls#2119
Optimize CloseInactiveSessionsJob: batch Redis calls#2119
Conversation
| var existingSessionHeartbeatIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| foreach (var sessionStart in results.Documents) | ||
| var documents = results.Documents as IReadOnlyList<PersistentEvent> ?? [.. results.Documents]; |
There was a problem hiding this comment.
Fixed — removed the weird as IReadOnlyList ?? [..] cast. The method now accepts IReadOnlyCollection and calls .ToList() internally, so the call site is clean.
|
|
||
| foreach (var sessionStart in results.Documents) | ||
| var documents = results.Documents as IReadOnlyList<PersistentEvent> ?? [.. results.Documents]; | ||
| var heartbeats = await GetHeartbeatsBatchAsync(documents); |
There was a problem hiding this comment.
should this just return a tuple with the doc and heartbeat and simplify below with a simple foreach?
There was a problem hiding this comment.
Good call — refactored GetHeartbeatsBatchAsync to return (PersistentEvent Session, HeartbeatResult? Heartbeat)[] tuples. The caller is now a simple foreach with deconstruction instead of index-based iteration.
There was a problem hiding this comment.
Pull request overview
This PR optimizes the CloseInactiveSessionsJob by batching Redis cache operations to reduce network roundtrips and trace noise. Previously, the job made 100–300 sequential GET requests per page of sessions (one per session for heartbeat data and close flags). The new implementation consolidates these into just 2 MGET calls per page using the cache client's GetAllAsync method.
Changes:
- Replaced per-session
GetHeartbeatAsyncmethod with batch-orientedGetHeartbeatsBatchAsyncthat processes an entire page at once - Changed from Dictionary keyed by domain objects to index-aligned arrays for tracking heartbeat results
- Maintained all existing business logic including session ID priority over user identity fallback, duplicate detection, and close flag handling
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| IDictionary<string, CacheValue<bool>> closeValues = closeKeys.Count > 0 | ||
| ? await _cache.GetAllAsync<bool>(closeKeys) | ||
| : new Dictionary<string, CacheValue<bool>>(); |
There was a problem hiding this comment.
not sure why this couldn't be inferred like this, I've noticed this a lot lately with llms and not generating modern c# or giving loops good variable names.
| IDictionary<string, CacheValue<bool>> closeValues = closeKeys.Count > 0 | |
| ? await _cache.GetAllAsync<bool>(closeKeys) | |
| : new Dictionary<string, CacheValue<bool>>(); | |
| IDictionary<string, CacheValue<bool>> closeValues = closeKeys.Count > 0 | |
| ? await _cache.GetAllAsync<bool>(closeKeys) | |
| : []; |
There was a problem hiding this comment.
Unfortunately [] doesn't compile here — IDictionary<string, CacheValue<bool>> is an interface and the compiler reports CS9174: Cannot initialize type 'IDictionary<...>' with a collection expression because the type is not constructible. We'd need the concrete Dictionary<> type, but GetAllAsync returns IDictionary. Keeping the explicit new Dictionary<...>() for now.
…ach loop Address PR feedback: - Remove weird IReadOnlyList cast at call site - GetHeartbeatsBatchAsync now returns (PersistentEvent, HeartbeatResult?) tuples - Caller uses simple foreach instead of index-based for loop - Accept IReadOnlyCollection to avoid cast at call site
Summary
The
CloseInactiveSessionsJobwas generating excessive Redis traffic — primarily individualGETcommands — causing noisy traces and unnecessary latency. This PR batches all cache reads into just 2 RedisMGETcalls per page instead of 100–300 sequentialGETcalls.Problem
For each open session (up to 100 per page), the job previously called
GetHeartbeatAsyncwhich issued 1–3 individual Redis GETs:GETfor the session-ID-based heartbeat timestampGETfor the user-identity-based heartbeat timestamp (fallback if Add Requirements Section #1 misses)GETfor the close flag (if a heartbeat was found)With 100 sessions per page, this produced 100–300 sequential Redis roundtrips per page, each generating its own trace span.
Solution
Replaced the per-session
GetHeartbeatAsync/GetLastHeartbeatActivityUtcAsyncmethods with a singleGetHeartbeatsBatchAsyncmethod that processes an entire page in 5 phases:GetAllAsync<DateTime>for all heartbeat timestampsGetAllAsync<bool>for all close flagsAdditional improvement
Dictionary<PersistentEvent, HeartbeatResult>keyed by domain objects with index-aligned arrays (HeartbeatResult?[]), avoiding reliance onGetHashCode/Equalsand reducing allocations.Impact
All existing session-closing semantics (duplicate detection, inactive period, close flags) are preserved.