Throttle checkin cache key refresh when the key list is empty - #281
Throttle checkin cache key refresh when the key list is empty#281jwakefield-secc wants to merge 1 commit into
Conversation
CheckinCache.UpdateKeys only honored its 10-second refresh interval when
the cached key list was already non-empty:
if ( currentKeys.Any() && ( now - _lastKeysRefreshUtc ) < KeysRefreshInterval )
An empty list is a legitimate result -- overnight, before the first
check-in of the day, there are no matching Attendance rows -- but the
guard treated it as a cold cache. Every caller therefore fell through to
keyFactory() instead of serving from cache, and the throttle never
applied.
AttendanceCache.KeyFactory() emits a full scan of dbo.Attendance
(~23,900 logical reads, ~1.8s CPU) to return zero rows; cost tracks index
size, not result size. CheckinMonitor.BindTable calls into the cache once
per open room on a 10-second render loop, so a single screen produced
~25,000 rebuilds an hour. Observed on rockprod as a sustained ~98% CPU
plateau from 01:00 until the day's first check-in, on roughly 65% of
nights.
Dropping currentKeys.Any() makes the throttle depend only on when the
list was last rebuilt. The throttled branch still applies ensureKey and
removeKey, so a live check-in's key is injected immediately with no
database round trip.
Measured on RockDev: 150 rebuilds/minute before, 5.2/minute after -- one
per ~11.5s against the 10s interval.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a performance issue in the FamilyCheckin cache key refresh logic by ensuring the 10-second refresh throttle applies even when the cached key list is legitimately empty (e.g., before the first check-in of the day). This prevents repeated expensive keyFactory executions that can occur in “empty but valid” scenarios.
Changes:
- Removed the
currentKeys.Any()gating from the key refresh throttle so throttling depends only on elapsed time. - Expanded inline documentation to clarify why empty key lists must still be throttled.
| // If within the throttle window, serve the cached key list as-is. | ||
| // An empty list is a valid answer (e.g. overnight, before the day's first | ||
| // check-in) and must still be throttled -- otherwise every caller re-runs | ||
| // the key query, which is a full scan of Attendance returning no rows. |
|
Good job tracking down this issue and finding a fix. I approve with two minor adjustments: 1) accept GitHub Copilot's suggestion to update the comment and 2) add a note in the README for the familycheckin project that explains the 10s empty cache, something like this, either in the Components area or Observations:
|
Problem
CheckinCache.UpdateKeysonly honored its 10-second refresh interval when the cached key list was already non-empty:An empty list is a legitimate result — overnight, before the day's first check-in, no
Attendancerows match — but this guard treated it as a cold cache. So every caller fell through tokeyFactory()instead of serving from cache, and the throttle never applied.AttendanceCache.KeyFactory()emits a full scan ofdbo.Attendance(~23,900 logical reads, ~1.8s CPU) to return zero rows. Cost tracks index size, not result size — the query is forced to scan rather than seek becauseCreatedDateTimeisdatetimewhile EF6 sends the parameter asdatetime2(7).CheckinMonitor.BindTablecalls into the cache once per open room on a 10-second render loop, so a single screen produced roughly 25,000 rebuilds an hour. Onrockprodthis showed as a sustained ~98% CPU plateau from 01:00 until the day's first check-in, on about 65% of nights and 84% of Sundays.Fix
Drop
currentKeys.Any(), so the throttle depends only on when the list was last rebuilt rather than on whether it happens to be empty.The throttled branch still applies
ensureKey/removeKey, so a live check-in's key is injected into the list immediately with no database round trip._lastKeysRefreshUtcstarts atDateTime.MinValue, so a cold start still loads on first call.Evidence
Measured on RockDev, which had been rebuilding continuously because its data ends 21 July and nothing is ever written "today":
Attendancerow made the list non-emptyThat's one rebuild per ~11.5s against the 10s interval — the throttle at its intended cadence. This PR produces the same behavior without needing a row to exist.
Production, 1 Aug (unmitigated) vs 2 Aug (row inserted as a stopgap), overnight hours:
Trade-off worth reviewing
If the cache entry is evicted (rather than being genuinely empty) within the throttle window, callers now get an empty list for up to 10 seconds instead of an immediate reload.
AllKeys()can't distinguish "loaded and empty" from "not loaded" — both return an empty list — so this is the cost of the minimal fix.Impact is bounded: 10 seconds, and the live check-in path is covered by
ensureKey. Against a six-hour 98% CPU plateau most nights, the trade seems clearly right, but it is a real behavior change and reviewers should weigh it.Not in this PR
CheckinMonitorrefresh guard — don't start a render while one is in flight; back off on consecutive failures. The 10s interval is hardcoded in the.ascx, so this is a code change, not block config. Affects blocks 1396 / 936 / 7167 (pages 465, 545, 3211).HasColumnType("datetime")onAttendance.CreatedDateTime— removes the per-row convert so the scan becomes a seek. Fixes cost rather than frequency. Note this raises executions/sec on its own, since the loop is duration-bound — it should land after this PR, not before.CloseOccurrenceidempotency — tolerate a zero-row delete and still flipOccurrenceCache.IsActive.Testing notes
To verify after deploy, on a database with no attendance rows for the current day:
Sample twice a minute apart with a Check-In Monitor open. Expect ~6/min, not ~150/min.
Note there are temporary stopgap rows in production and DEV holding the key list non-empty until this ships — remove them after deploy so the fix is actually exercised:
The prod row is dated 5 August, so protection lapses on the 6th regardless.
🤖 Generated with Claude Code