MRR Dashboard: Redis-backed cache + Scheduled WebJob pre-warm¶
Context¶
The /Customer/MrrTransactionSummary endpoint (consumed by crm.raklet.com/Customers) timed out on production after we shipped the dashboard. The prior commit on this branch (fix/mrr-dashboard-perf, PR #13705) deduped and parallelised the Stripe lookups, dropping cold-compute time roughly 10×. That fixes the acute symptom (a request now completes within the request timeout) but the dashboard still recomputes from scratch on every cache miss because of two latent bugs:
- The existing cache key is effectively unique per request.
MrrTransactionSummarybuilds it ascrm-mrr-summary:{since:O}:{until:O}wheresince/untilareDateTime.UtcNow-derived with millisecond precision. Two requests one ms apart get two different keys; the 10-minuteHttpRuntime.Cacheinsert is essentially write-only. HttpRuntime.Cacheis per-AppDomain. Raklet.Crm runs on multiple Azure instances. Each instance maintains its own cache, and the cache is wiped on every app-pool recycle. There is no shared, persistent warm cache.
Goal: every user request to the default 30-day dashboard view hits a pre-warmed Redis entry, so cold-compute work happens only inside a WebJob — never on the user request path.
Approach¶
Three coordinated changes:
- Extract the dashboard compute into
Services/MrrDashboardService.csso both the controller and a WebJob can invoke it. The controller becomes thin (parse inputs → load rows → filter/sort/paginate → return JSON). The service owns the Stripe enrichment plus a singleWarmDefaultCache()entrypoint. - Switch the cache from
HttpRuntime.CachetoCacheService(Redis) with a stable key parameterised bydaysonly, so the warmer and the controller agree on the slot. - Add a new Scheduled5mins trigger + handler in
Raklet.WebJobs.Secondarythat callsMrrDashboardService.WarmDefaultCache()every 10 minutes. Only the 30-day window is pre-warmed (per scope decision).
The synchronous fallback stays: if Redis is empty (first deploy, WebJob failed, Redis cold), the controller still computes inline so the dashboard is never dead.
Files to modify¶
New files¶
Services/MrrDashboardService.cs— owns:- All private helpers currently in
CustomerController.cs(GetApplicationFeeMap,GetOrganisationStripeConnectAccountMap,GetConnectAccountMetricsMap,GetConnectAccountLifetimeTxnMap,GetPlanMap,GetSubscriptionAggregateMaps,GetCustomerLifetimeRevenueMap,BuildMrrTxRows,ApplySorting, and the model typesMrrTxOrganisationRow,MrrTxCachePayload,MrrTxPlanData,MrrTxRowViewModel,MrrTxSubscriptionSnapshot,MrrTxConnectMetrics,MrrTxSubscriptionAggregate). - New public API:
MrrDashboardService.GetOrBuildSummary(int days, DateTime? customStartUtc, DateTime? customEndUtc, bool bypassCache)→MrrTxCachePayloadMrrDashboardService.WarmDefaultCache()→ computes the 30-day payload and writes it to Redis under the canonical keyMrrDashboardService.GetCanonicalCacheKey(int days)→string(e.g."crm-mrr-summary:days:30") — used by both warmer and reader
Raklet.WebJobs.Secondary/CrmDashboard/MrrDashboardWarmFunctions.cs(placement mirrors the existingRaklet.WebJobs.Secondary/Email/EmailFunctions.csconvention):public static async Task TriggerMrrDashboardWarm([QueueTrigger("trigger-mrr-dashboard-warm")] string message, TextWriter log)— guards withWebJobUtil.IsDeploymentTime, acquires a 12-minute Redis lockisProcessingMrrDashboardWarm(matching the pattern inEmailFunctions.TriggerEmailStats), then callsnew MrrDashboardService(new RakletDb()).WarmDefaultCache().
Edited files¶
Raklet.Crm/Controllers/CustomerController.csMrrTransactionSummaryshrinks to: parse query params →var payload = MrrDashboardService.GetOrBuildSummary(days, startUtc, endUtc, bypassCache)→ applyplan/txOnly/searchfilters, sort, paginate → returnJson(...). The filtering/sorting/pagination logic stays in the controller because it's pure presentation and varies per request.- All the static helpers / model types move out (deletions in this file).
Raklet.WebJobs.Scheduled5mins/Functions.cs(dictionary at lines 13–50)- Add one entry:
{"trigger-mrr-dashboard-warm", new[] { 0, 10, 20, 30, 40, 50 }}— fires every 10 minutes. Services/Services.csproj— add<Compile Include="MrrDashboardService.cs" />(perCLAUDE.md: non-SDK csproj, manual registration required).Raklet.WebJobs.Secondary/<csproj>— add<Compile Include="CrmDashboard\MrrDashboardWarmFunctions.cs" />.
Cache key + TTL design¶
- Default days view (the only view we pre-warm): key =
"crm-mrr-summary:days:30". The warmer overwrites every 10 minutes; Redis TTL = 12 minutes (one warm-cycle of grace). The cachedMrrTxCachePayloadcarriesCachedAtUtc,SinceUtc,UntilUtcso the response can show freshness, even though the key itself is parameterless beyonddays. - Custom
daysother than 30 (e.g. 7, 60, 90): same key shape"crm-mrr-summary:days:{days}", 12-minute Redis TTL. Not pre-warmed, so the first hit perdaysvalue is a cold compute; subsequent hits within 12 minutes are warm. Acceptable per scope decision. - Explicit
startUtc/endUtc(custom range): key ="crm-mrr-summary:range:{since:yyyyMMddHHmm}:{until:yyyyMMddHHmm}"rounded to the minute, 5-minute TTL. Not pre-warmed. bypassCache=true: deletes the relevant key, then recomputes and stores.
Reused utilities¶
Services/CacheService.cs—Get<T>,Set<T>(key, value, TimeSpan expiry),Purge(key). JSON-serialised via Newtonsoft; lazyConnectionMultiplexeralready initialised.MrrTxCachePayloadis a POCO of simple types — serialises fine.Raklet.WebJobs.Secondary/Email/EmailFunctions.cs(TriggerEmailStats, ~line 1817) — reference implementation for the new handler (lock-guard + direct Service invocation, no HTTP).Services/WebJobUtil—IsDeploymentTime(log)re-queues the message during deploy windows. Used in every existing handler; copy that pattern.Services/QueueService— already used byScheduled5mins/Functions.csto drop trigger messages; no change required there beyond the dictionary entry.
Verification¶
After implementing, validate end-to-end:
- Local build:
scripts/dev/build-fast.ps1should buildServices,Raklet.Crm,Raklet.WebJobs.Scheduled5mins,Raklet.WebJobs.Secondary(or whichever the dependency analyser picks up). - Controller smoke test (local Raklet.Crm, IIS):
- Cold call:
https://crm.raklet.local/Customer/MrrTransactionSummary?days=30&bypassCache=true→ completes, response showsCachedAtUtc≈ now. - Warm call: same URL without
bypassCache→ completes in well under a second, same payload,CachedAtUtcunchanged. - Confirm Redis key exists (
redis-cli GET crm-mrr-summary:days:30, or Azure Portal) — returns a JSON blob. - WebJob smoke test (local Scheduled5mins console):
- Wait for a
0/10/20/30/40/50minute boundary OR manually enqueue:QueueService.SendMessage(new QueueType("trigger-mrr-dashboard-warm")). - Watch Secondary WebJob console: handler picks up the message, logs lock acquisition, runs
WarmDefaultCache, logs duration. - Confirm Redis key is refreshed (
CachedAtUtcadvances). - Multi-instance verification (post-deploy, prod-like): hit
/Customer/MrrTransactionSummaryagainst two CRM instances behind the load balancer; both should return the sameCachedAtUtc(proves shared Redis read), and the response should be sub-second from both even immediately after one instance recycles. - Failure modes:
- Stop the WebJob → user request still works (synchronous fallback computes inline, writes Redis).
- Stop Redis → user request still works (the controller logs the Redis failure via the existing
CacheServiceswallowed-exception path and falls through to compute; do not introduce a hard dependency). - Confirm via a deliberate connection-string break in a local config.
- Stripe load: in prod, watch the Stripe dashboard for request volume. With one WebJob warming every 10 minutes, the per-customer subscription enrichment runs 6× per hour total across the entire cluster (instead of N-instances × per-user request). Should be well under Stripe rate limits.
Out of scope (defer to a later PR)¶
- Pre-warming for
days≠ 30. Add only if dashboard analytics show meaningful usage. - Per-customer Stripe data caching with longer TTL (would let the WebJob refresh incrementally). The current full-payload cache is simpler and covers the cold-start goal.
- A
/CustomerWebhook/InvalidateMrrCacheendpoint to purge the warm cache when a subscription state change happens. Not needed at 10-minute staleness target.