Skip to content

Incident: 2026-02-06 — Production Performance Degradation

Summary

Transportation billers unable to save monthly visits (UI spinner/timeout). Investigation revealed database-wide performance degradation caused by expensive clinician dashboard queries and report queries saturating DTU capacity during business hours.

FieldValue
SeveritySEV2 — Major degradation
StatusInvestigating
Reported byKisha Pinkney
Investigated byLeon Geldenhuys
Started2026-02-06 ~14:00 UTC
ResolvedPending
Affected usersTransportation billers (confirmed), likely all users
Affected endpointPOST Visit/SaveTransportationVisit (16.2s avg, 617 calls)

Timeline

Time (UTC)Event
~14:00Transportation billers report spinner when saving monthly visits
~14:00DTU usage spikes to 900 DTU (90% of 1,000 limit)
~14:00–15:00CPU reaches 100%, DTU limit hit for ~10 seconds
~14:18Tooltip captured: DTU used avg 142.41 (post-spike)
~15:00Spike subsides; DTU settles to sustained ~470
~18:00DTU remains at ~470 sustained into overnight
Feb 07Investigation begins; similar pattern observed — spikes at same time daily
Feb 07DTU Limit chart reveals scheduled scaling: 500 DTU from 2 AM – 12 PM
Feb 07Active sessions query shows 28 SSRS-like sessions from DB000004
Feb 07Query Store (14:00–15:00 window): clinician search (9 min avg), report query (11.7M reads)
Feb 07Query Store (16:36 precise): UpdateVisitStatus N+1 pattern (1,787 calls × 2.3s), visit cross-join (55s CPU)
Feb 07App Insights: worst SaveTransportationVisit = 42,079ms, user KMgQL (Allen, TX)
Feb 07App Insights: SQL command text not captured — EnableSqlCommandTextInstrumentation not configured
Feb 07App Insights Performance: app-wide degradation — 9 endpoints over 5 seconds, smart detection flagging 3

Investigation

1. Azure Metrics (DTU)

DTU Limit chart revealed a scheduled scaling rule:

Time WindowDTU LimitDTU Used (peak)Utilization
Midnight – 2 AM1,000~470 sustained47%
2 AM – 12 PM (scaled down)500~470 sustained94%
12 PM onwards1,000900 peak90%

The database is scaled down to 500 DTU from 2 AM to noon — leaving almost no headroom during morning business hours when users are most active.

Deadlocks: None reported during the spike window.

2. Active Sessions (Saturday snapshot)

Query: sys.dm_exec_sessions joined with sys.dm_exec_requests

program_namehost_nameSessionsActive Requests
.Net SqlClient Data ProviderWN1LDWK0001KB192
.Net SqlClient Data Providervm-jobscheduler92
TdServiceDB00000462
Framework Microsoft SqlClient Data ProviderDB000004280
.Net SqlClient Data ProviderWN0LDWK0003AX170
Core .Net SqlClient Data Providerpd0sdwk001EC250

Key observations:

  • DB000004 hosts 28 idle sessions via “Framework Microsoft SqlClient Data Provider” — likely SSRS running on the database server itself
  • TdService on DB000004 with 2 active requests — unknown service, needs identification
  • vm-jobscheduler — dedicated Quartz.NET job scheduler VM

3. Query Store Analysis (Spike Window: 14:00–15:00 UTC)

Top CPU consumers during the spike:

#QueryExecutionsAvg CPUAvg DurationAvg ReadsIdentification
1@clinicianuserId, @firstName...1813.6s9 min33KClinician dashboard/search
2@p_linq_0 nvarchar(4000)... SELECT42,5676ms6ms415High-volume EF/LINQ query
3select distinct NS.Id, NS.StudentId...375.4s1.4s42KStudent query (parallel exec)
4WITH FilteredStudents AS...135594ms627ms355KStudent list/grid
5@todaysDate, @organizationId, @firstName...871.1s480ms316KClinician search by org
8@studentFirstNameFilter...761.2s604ms210KStudent name search
9@StartDate, @EndDate SELECT DISTINCT S...140s49s11.7MSingle report query
13INSERT INTO #tempTripData13,0331.8ms1.8ms196Transportation billing (victim)

Analysis:

  • Query #1 is the primary offender — 181 executions, each averaging 9 minutes wall time but only 3.6s CPU. The 8.5-minute gap = waiting for locks/IO from other queries. This is a clinician dashboard or search query.
  • Query #9 — a single report execution consuming 40 seconds CPU and 11.7 million reads. This alone would spike DTU.
  • Query #13 (transportation billing) — individually fast (1.8ms) but competing for resources while queries #1 and #9 choke the database. The biller’s spinner is a symptom, not the cause.

4. Query Store — Precise Spike Correlation (16:36 UTC)

Narrowed Query Store to the exact time of the worst SaveTransportationVisit request (42 seconds at 4:36 PM):

#query_idQueryAvg DurationAvg CPUExecutionsImpact
165638416UpdateVisitStatus.Vi... @isCallingFromBulkMove2.3s1.3s1,787The save operation itself
265637656@studentId, @startDateForBillingSearch...8.7ms5.1ms213,341Billing search (volume)
365643379@p_linq_0 nvarchar(4000)... SELECT [Limit...]19ms15ms73,807EF/LINQ query (volume)
465704168@visitStartDate, @visitEndDate, @districtSe...1.8ms1.1ms314,608Visit date filter (volume)
565715521@p_linq_0 int, @p_linq_1 datetime2(7)...5.9s19.7s82Heavy EF query (parallel)
665637655@studentId, @startDateForBillingSearch...4.4ms2.5ms96,663Billing search variant
765637469UPDATE ts SET ts.IsRequired = 1 FROM #tempServices20ms0.9ms20,167Transportation billing temp
865518832@todaysDate, @organizationId, @firstName...1.8s1.2s196Clinician search by org
965243466@todaysDate, @clinicianuserId, @firstName...983ms3.7s283Clinician search
1065715544SELECT A1.VisitId VisitID1, A2.VisitId VisitID2...21.8s55s11Visit cross-join (monster)

Root cause chain identified:

  1. SaveTransportationVisit calls UpdateVisitStatus (query_id 65638416) once per visit in a loop — classic N+1 pattern
  2. Each UpdateVisitStatus takes 2.3 seconds because the database is saturated by:
    • Query #10: a visit cross-join consuming 55 seconds of CPU per execution (11 executions)
    • Queries #2, #4, #6: 624,000 combined billing search executions creating constant baseline load
  3. Saving ~18 visits sequentially: 18 × 2.3s = 41.4 seconds (matches the observed 42-second request)

Query #10 is the single worst querySELECT A1.VisitId VisitID1, A2.VisitId VisitID2 with only 11 executions but 55 seconds CPU each. This is a visit-to-visit comparison or duplicate detection query that needs immediate investigation.

5. Full Query Text and Codebase Mapping

Query #10 — Overlapping Activities (55s CPU)

Stored procedure: usp_GetOverlappingVisitList File: Eshars.DbUpgrader/Sql/App/Stored Procedures/usp_GetOverlappingVisitList.sql

SELECT A1.VisitId VisitID1, A2.VisitId VisitID2
INTO #OverlappedActivities
FROM #TempActivities AS A1
INNER JOIN #TempActivities AS A2
ON A1.ClinicianId = A2.ClinicianId
AND A1.StartDate < A2.EndDate
AND A1.EndDate > A2.StartDate
WHERE A1.Id <> A2.Id

Problem: O(N²) self-join on #TempActivities — for each clinician, every activity is compared to every other activity. With large datasets this explodes.

Called by:

  • BulkMoveJob.cs:335VisitService.GetOverlappingVisitList()
  • VisitService.cs:1863-1874 — passes comma-separated visit IDs to the SP

Query #1 — UpdateVisitStatus (2.3s × 1,787 calls)

Two stored procedures exist:

  • Single-visit: UpdateVisitStatus (takes @visitId INT) — Eshars.DbUpgrader/Sql/App/Stored Procedures/UpdateVisitStatus.sql
  • Batch version: sp_GetNextStatusForVisits (takes @visits dbo.UpdateVisitStatusType READONLY TVP) — Eshars.DbUpgrader/Sql/App/Stored Procedures/sp_GetNextStatusForVisits.sql

Both call dbo.GetNextStatusForVisit — a 745-line table-valued function containing eligibility checks (age, 365/95 day rule, Medicaid, consent, special ed, transportation matching, billing limits, Rx requirements, supervisor approval, DNQ checks).

The N+1 call chain:

  1. VisitController.SaveTransportationVisit() (line 2365) — receives List<AddVisitModel>
  2. VisitService.SaveTransportationVisits() (line 1438) — loops with visits.ForEach() (line 1457)
  3. UpdateVisit() — called per visit inside the loop
  4. UpdateVisitStatus SP — calls GetNextStatusForVisit (745 lines) per visit

Key finding: A batch version (sp_GetNextStatusForVisits) already exists and is used by BulkMoveJob. The SaveTransportationVisit endpoint does NOT use it — it calls the single-visit SP in a loop.

6. Application Insights — Request Correlation

Queried SaveTransportationVisit requests sorted by duration:

Timestamp (UTC)DurationResultPerformance Bucket
4:36:22 PM42,079ms200 OK30sec-1min
4:34:36 PM17,999ms200 OK15sec-30sec
5:05:56 PM4,962ms200 OK3sec-7sec
4:52:48 PM3,703ms200 OK3sec-7sec
3:38:59 PM3,688ms200 OK3sec-7sec
4:58:00 PM375ms200 OK250ms-500ms
3:00:05 PM332ms200 OK250ms-500ms

Key finding: No requests timed out — all returned 200. The problem is pure slowness, not failures. The 42-second save corresponds to the same user (user_Id: KMgQL, location: Allen, TX) who was on the User Timeline view.

Limitation: App Insights dependency tracking does not capture SQL command text — EnableSqlCommandTextInstrumentation is not configured in ApplicationInsights.config. This prevented direct correlation from the App Insights request to the specific SQL call. Root cause was identified via Query Store timestamp correlation instead.

8. Frontend & Timeout Stack Analysis — Why “Can’t Save”

Users report “can’t save” despite server returning 200 OK. Investigation of the full request pipeline:

Client-side AJAX (Eshars.WebUI/Scripts/app/common/ajaxHelper.ts:19-28):

  • $.ajax call with no timeout property — jQuery defaults to infinite wait
  • Spinner shown via AjaxHelper.showAjax("Saving visit(s)") before AJAX call
  • Spinner hidden in .done() callback (line 2639) AND .fail() callback (line 2822-2824)
  • .fail() handler also shows generic error toast via handleError()

Server-side configuration (Web.config):

SettingValueEffect
httpRuntime executionTimeout1800 (30 min)Server keeps processing long after client disconnects
forms timeout5400 (90 hrs)Auth cookie does not expire
sessionState timeout5400 (90 hrs)Session does not expire
SQL CommandTimeout30 sec (default — no custom override found)Individual queries can timeout under load

Azure infrastructure:

LayerTimeoutConfigurable?
Azure SQL connection30 secondsYes (connection string)
Azure SQL command30 seconds (default)Yes (per-command)

The failure scenario for a full-month save:

A transportation biller saving a full month selects ~44 checkboxes (22 working days × AM+PM). The extractVisitsForSave() function in transportationServices.ts:279-326 creates one visit per checkbox per student.

ScenarioVisitsTime per visitTotalResult
Normal day44~0.5s~22sSlow but works
During DTU spike (observed)18~2.3s~42sWorks, but 42s spinner
DTU at 100%44~5-10s220-440sSome visits exceed 30s SQL timeout → partial save
Multiple students88+~2.3s200+sMany visits may timeout individually

Per-visit SQL timeout — the primary failure mechanism:

  • Inside the visits.ForEach() loop, each call goes through AddNewVisit()UpdateVisitStatus()GetNextStatusForVisit (745-line TVF)
  • Default SQL CommandTimeout is 30 seconds (no custom override found in the app)
  • Under DTU saturation, individual GetNextStatusForVisit calls can exceed 30s → SqlTimeoutException
  • The per-visit catch block (VisitService.cs:1495) catches this and sets CanBeScheduled = false
  • Response returns 200 OK with hasErrors = true → client shows partial-save error dialog via showSaveVisitErrorResponses()
  • Some visits saved, some didn’t → confusing partial-save state for the user
  • Observed: avg 2.3s per call during spike, but outliers under full saturation can reach 30s+

User experience during degradation:

  1. User clicks Save → spinner shows (“Saving visit(s)”)
  2. Server processes visits one-by-one (N+1 loop), some succeed, some fail with SQL timeout
  3. After 42+ seconds, response arrives with hasErrors = true
  4. User sees error dialog listing failed visits — but some visits DID save
  5. User may retry, creating duplicates for the visits that already saved

ELMAH correlation: Anti-forgery token exceptions were found in ELMAH during the spike window. These are consistent with users who waited a long time, navigated away or refreshed, then retried — the token state became inconsistent between attempts.

6. Application Insights — Slowest Operations (last 24h)

OperationAvg DurationCount
POST Visit/SaveTransportationVisit16.2 sec617
POST Reports/GetServiceCategories15.6 sec4
GET Account/ForgotPasswordConfirmation8.36 sec146
GET Students/StudentGoals7.60 sec59
POST SAFormSubmittal/SAForm7.19 sec98
POST Visit/GetCliniciansForRapidVisits7.13 sec1
GET Jobs/Index6.84 sec21
POST EDIManagement/GetBillingTransactionFiles5.72 sec21
POST Visit/SaveAssessmentVisits5.61 sec36

Smart Detection alerts (Insights panel):

AlertDegradationPrevious AvgDetected
GET Visit/ShowVisitDetails144%357ms872ms
POST Notifications/GetApproveRejectVisitsCount129%893ms2.05s
POST Message/GetUnreadMessagesCount202%372ms1.12s

The degradation is app-wide, not limited to transportation billing.

7. App Service Configuration

Logging settings on wa-eshars-prod:

SettingValue
Application logging (Filesystem)Off
Application logging (Blob)On — Verbose — diagnosticsesharsprod container
Web server loggingFile System — 35 MB quota
Detailed error messagesOn
Failed request tracingOn
Blob retention periodNot set (accumulating indefinitely)

Root Cause

Primary cause: The SaveTransportationVisit endpoint calls UpdateVisitStatus once per visit in a sequential loop (N+1 pattern). Each call takes 2.3 seconds because the database is saturated by a visit cross-join query (55s CPU per execution) and 624,000+ billing search queries creating constant load.

Why billers “can’t save” (not just “slow save”):

When DTU is at 100%, the N+1 loop processes visits sequentially — each calling GetNextStatusForVisit (745-line TVF). With a default SQL CommandTimeout of 30 seconds, individual visits in the loop start timing out with SqlTimeoutException. These are caught per-visit (VisitService.cs:1495) and marked CanBeScheduled = false. The response returns 200 OK but with hasErrors = true, and the user sees a partial-save error dialog — some visits saved, some didn’t. On retry, the already-saved visits may be duplicated (no idempotency check). Even when no SQL timeouts occur, the 42+ second spinner causes users to abandon and retry, compounding the problem.

The transportation biller’s spinner is a symptom of three compounding issues:

  1. N+1 save patternUpdateVisitStatus called per-visit instead of batched. ~18 visits × 2.3s = 42 seconds; full month = 100+ seconds
  2. SQL CommandTimeout (30s) — individual visits in the loop can timeout during DTU saturation, causing partial saves
  3. Visit cross-join query (query_id 65715544) — SELECT A1.VisitId, A2.VisitId runs 11 times at 55s CPU each, saturating the database

Contributing factors:

  1. DTU scaling rule reduces capacity from 1,000 to 500 DTU during 2 AM – 12 PM, leaving almost no headroom when users start their workday
  2. No read replica — report queries and application writes compete on the same database (see RFC-0001)
  3. DashboardService uses writable database context for all 12 pure-read methods, including stored procedures with infinite timeouts (see Read Replica Audit)
  4. 28 SSRS sessions from DB000004 — report server appears to run on the database server
  5. SQL CommandTimeout = 30s (default) — individual visits in the N+1 loop can timeout, causing partial saves with confusing error messages
  6. App Insights SQL command text not enabledEnableSqlCommandTextInstrumentation is not configured, making request-level SQL tracing impossible
  7. No server-side progress/idempotency — no mechanism to resume or detect partial saves, leading to duplicates on retry

Outstanding Questions

  • What is the full query text for query_id 65715544?usp_GetOverlappingVisitList — O(N²) self-join
  • What is the full query text for query_id 65638416?UpdateVisitStatus SP calling 745-line GetNextStatusForVisit function
  • What is TdService running on DB000004?
  • What is the full query text for query_id 65243466 (clinician dashboard query)?
  • Who configured the DTU scaling rule and was it intentional?
  • Is SSRS running on DB000004 (the database server)?
  • Does this pattern repeat daily? (initial evidence suggests yes — “same time yesterday”)
  • Why are there 624,000+ billing search queries in a single hour?
  • Can GetNextStatusForVisit (745 lines) be optimized or precomputed?

Remediation

Immediate (today)

  • Review and fix the DTU scaling schedule — do not scale below 1,000 during business hours
  • Get full query text for query_id 65715544usp_GetOverlappingVisitList (O(N²) self-join)
  • Get full query text for query_id 65638416UpdateVisitStatus SP calling 745-line GetNextStatusForVisit
  • Investigate why 624,000+ billing search queries fire in a single hour

Short-term (this sprint)

  • Refactor VisitService.SaveTransportationVisits to use sp_GetNextStatusForVisits (batch TVP) instead of per-visit UpdateVisitStatus loop — batch version already exists and is used by BulkMoveJob (highest impact fix)
  • Optimize usp_GetOverlappingVisitList — replace O(N²) self-join with window function approach
  • Enable SQL command text in App Insights (EnableSqlCommandTextInstrumentation)
  • Review missing index recommendations from sys.dm_db_missing_index_details
  • Set blob log retention period on App Service
  • Implement read replica for report queries (RFC-0001)
  • Refactor DashboardService to use IUnitOfWork<ReportDbReadOnlyContext> for all 12 read methods
  • Add ApplicationIntent=ReadOnly to SSRS data source connection strings

Long-term

  • Move SSRS off the database server if confirmed co-located
  • Add DTU usage alerting (threshold > 80%)
  • Build eshars-cli db health command for quick production triage
  • Create proactive monitoring dashboard (Power BI or Grafana)
  • Audit all high-volume query patterns for N+1 anti-patterns
  • Add idempotency keys to visit creation to prevent duplicates on retry
  • Consider adding a client-side progress indicator or batched save with status callback