Ease up importer CPU: configurable aggressiveness + daily-gate non-fresh Graph imports + stagger WebJobs - #162
Ease up importer CPU: configurable aggressiveness + daily-gate non-fresh Graph imports + stagger WebJobs#162sambetts wants to merge 3 commits into
Conversation
…sh Graph imports, stagger WebJobs (#161) Reduces 100% CPU spikes from the two importer WebJobs on the shared App Service plan without losing freshness on audit events & hits. - New AppConfig keys (optional AppSettings, preset-driven, explicit overrides): ImportAggressiveness (High|Balanced|Gentle, default Balanced), MaxAuditReportLoadConcurrency, ImportCyclePauseMinutes, GraphMetadataImportIntervalHours (default 24h), ImportStartStaggerMinutes. - Daily-gate user metadata / user apps / Teams imports via a Redis last-run gate (previously ran every 10-min cycle for no freshness benefit). - Cap audit full-load thread fan-out (was hardcoded 20) via the preset. - Configurable inter-cycle pause (was hardcoded 10 min) for both WebJobs. - Stagger the hits WebJob's first cycle so it doesn't peak with the activity WebJob on the shared plan. - Unit tests for all new config parsing / preset / override behaviour. Default preset Balanced eases up for everyone on upgrade; audit events & hits keep the 10-min cadence (only burst concurrency lowered). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ry fallback + force flag The previous commit added RunGraphSectionIfDueAsync but never called it, so the daily-gating the PR is named after never actually ran. This wires it up and addresses the review findings: - Wire the user-metadata / user-apps / Teams sections to the cadence gate (the headline feature now actually runs; previously the inline blocks ran every cycle). - New IImportLastRunStore with Redis + in-memory implementations, created once in Program.cs and threaded through ProgramTasks -> GraphImporter. The gate now works even when Redis is NOT configured (in-memory fallback, resets on restart) and persists across cycles when it is. Redis reads/writes are fail-open: a cache outage logs a warning and lets the import run, rather than skipping it (these imports previously had no Redis dependency at all). - ForceGraphMetadataImport flag (mirrors ForceUsageReportsImport) to force a one-off re-import without hand-deleting Redis keys. - ImportAggressiveness=High now truly restores legacy behaviour: the non-fresh Graph interval is preset-derived (High=0 every-cycle, Balanced/Gentle=24h), not a fixed 24h. - Teams gets its own GraphTeamsImportIntervalHours so its fresher channel/message analytics can be kept current without un-gating the static user-metadata/apps imports. - Gate decision extracted to ImportCadenceGate.ShouldRun (unit tested) + InMemoryImportLastRunStore round-trip tests + AppConfig tests for the new keys. Full Release build clean; 46 targeted tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ry fallback + force flag The previous commit added RunGraphSectionIfDueAsync but never called it, so the daily-gating the PR is named after never actually ran. This wires it up and addresses the review findings: - Wire the user-metadata / user-apps / Teams sections to the cadence gate (the headline feature now actually runs; previously the inline blocks ran every cycle). - New IImportLastRunStore with Redis + in-memory implementations, created once in Program.cs and threaded through ProgramTasks -> GraphImporter. The gate now works even when Redis is NOT configured (in-memory fallback, resets on restart) and persists across cycles when it is. Redis reads/writes are fail-open: a cache outage logs a warning and lets the import run, rather than skipping it (these imports previously had no Redis dependency at all). - ForceGraphMetadataImport flag (mirrors ForceUsageReportsImport) to force a one-off re-import without hand-deleting Redis keys. - ImportAggressiveness=High now truly restores legacy behaviour: the non-fresh Graph interval is preset-derived (High=0 every-cycle, Balanced/Gentle=24h), not a fixed 24h. - Teams gets its own GraphTeamsImportIntervalHours so its fresher channel/message analytics can be kept current without un-gating the static user-metadata/apps imports. - Gate decision extracted to ImportCadenceGate.ShouldRun (unit tested) + InMemoryImportLastRunStore round-trip tests + AppConfig tests for the new keys. Full Release build clean; 46 targeted tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pushed �cafe80 addressing review feedback (self + code-review agent):
Wiki: added a Forcing a re-import (clearing the last-run flags) section (force flag + |
Load test — importer aggressiveness (PR #162 / issue #161): App-Service CPU and SQL CPU/DTUBranch under test:
TL;DR
Method & caveats
Results — median of 3 reps, 100k items per area1) Audit load fan-out (
|
| Setting (cap) | wall s | importer CPU-s | importer peak %CPU |
|---|---|---|---|
| Current/High (20) | 1.78 | 2.2 | 428% |
| Balanced (8) | 1.58 | 2.1 | 300% |
| Gentle (3) | 1.59 | 2.1 | 373% |
1-vCPU emulation (pinned to 1 core): peak ≈108–126%, wall ≈1.9–2.0 s at every cap. Total importer
CPU ~flat (same work); the cap bounds the burst width.
2) SQL commit fan-out, isolated (InsertBatch, empty merge) — the lever PR #162 MISSES
| Setting (cap) | wall s | SQL CPU-s | SQL peak %CPU | importer CPU-s |
|---|---|---|---|---|
| Current/High (20) | 12.48 | 27.0 | 401% | 31.4 |
| Balanced (8) | 9.60 | 21.8 (−19%) | 430% | 24.1 |
| Gentle (3) | 14.47 | 20.6 (−24%) | 310% (−23%) | 43.1 |
Insert-bound ⇒ the cap is a strict win at 8: −19% SQL CPU and −23% wall vs 20.
3) Real merge-bound importers (InsertBatch staging + merge scripts) — cap cuts the peak, not the work
3a) Copilot (CommitAllChanges: 3 staging + merges)
| Setting (cap) | wall s | SQL CPU-s | SQL peak %CPU |
|---|---|---|---|
| Current/High (20) | 52.0 | 65.0 | 401% |
| Balanced (8) | 56.8 | 65.0 | 400% |
| Gentle (3) | 63.1 | 59.5 | 258% (−36%) |
3b) Hits (PageViewCollection.SaveToSQL: stage + 13-lookup merge) — heaviest SQL area
| Setting (cap) | wall s | SQL CPU-s | SQL peak %CPU |
|---|---|---|---|
| Current/High (20) | 50.6 | 143.0 | 550% |
| Balanced (8) | 45.6 | 137.2 (−4%) | 543% |
| Gentle (3) | 62.0 | 125.5 (−12%) | 358% (−35%) |
3c) Power Platform (CommitAllChanges: 6 staging + merges)
| Setting (cap) | wall s | SQL CPU-s | SQL peak %CPU |
|---|---|---|---|
| Current/High (20) | 29.8 | 44.3 | 482% |
| Balanced (8) | 33.5 | 43.1 | 458% |
| Gentle (3) | 38.2 | 43.8 | 327% (−32%) |
All three: SQL-CPU work ~flat across caps (merge dominates); peak drops ~30–36% at Gentle; wall
increases as the cap drops. So the cap is a spike lever for these, not a work-reduction lever.
4) Sent Email — real importer (SentEmailImporter, EF SaveChanges) — off the lever
| Setting | wall s | SQL CPU-s | SQL peak %CPU | importer CPU-s |
|---|---|---|---|---|
| (cap-independent) | 125.6 | 113.0 | 143% | 17.1 |
Chatty EF (per-chunk SaveChanges + IN-clause existing-key lookups) ⇒ high sustained SQL CPU but
no spike (peak ~1.4 cores). Not on the InsertBatch ParallelListProcessor, so the aggressiveness cap
can't change it. Worth its own optimisation later, but it isn't a CPU spike source.
5) Usage activity — SqlBulkCopy 100k rows — off the lever, cheapest
| Setting | wall s | SQL CPU-s | SQL peak %CPU |
|---|---|---|---|
| (cap-independent) | 5.3 | 2.0 | 114% |
SqlBulkCopy (and the prod TVP+upsert proc) are very efficient — the least SQL CPU of any area. Off-lever.
Cross-area SQL CPU per 100k (at current cap 20) — the DTU picture
| Area | SQL CPU-s / 100k | SQL peak | Lever / bound by |
|---|---|---|---|
| Hits | ~143 | ~550% | InsertBatch + heavy MERGE — cap = peak only |
| Sent email | ~113 | ~143% | EF SaveChanges — off lever, sustained, no spike |
| Copilot | ~65 | ~401% | InsertBatch + MERGE — cap = peak only |
| Power Platform | ~44 | ~482% | InsertBatch + MERGE — cap = peak only |
| Audit-event persistence ≈ plain InsertBatch | ~27 | ~401% | InsertBatch INSERT — cap = work −19% @8 |
| Usage (SqlBulkCopy) | ~2 | ~114% | bulk/TVP — off lever, cheap |
| Audit load (download/parse) | ~0 (CPU on App Service) | — | audit-load cap (the PR's lever) |
Per-area → which lever applies (all measured)
| Import job | Persistence | Audit-load cap (PR #162) | SQL-commit cap (InsertBatch) | Dominant SQL cost |
|---|---|---|---|---|
| Audit events | EFInsertBatch + merge | ✅ throttled | insert | |
| Hits | EFInsertBatch + heavy merge | — | MERGE (biggest) | |
| Copilot | 3× InsertBatch + merges | — | merge | |
| Power Platform | 6× InsertBatch + merges | — | merge | |
| Sent emails | EF SaveChanges |
— | ❌ off lever | EF (sustained, low peak) |
| Usage reports | TVP + upsert proc / SqlBulkCopy |
— | ❌ off lever | bulk (cheap) |
Recommendation for PR #162 (to make it "also about SQL CPU/DTU")
- Add
MaxSqlCommitConcurrency(preset 20/8/3, explicit-override, same pattern as
MaxAuditReportLoadConcurrency) →InsertBatch.SaveToStagingTable'sParallelListProcessor
(single choke point; the harness branch already prototypes
InsertBatchConcurrency.MaxConcurrentThreads). Default Balanced = 8 ⇒ less SQL CPU and
faster for the insert-bound audit/hits staging, and a lower commit peak on every InsertBatch
importer (hits/Copilot/PP) — directly the "100% spike" the customer alerts on. - Tune the merges for the real DTU win — hits (~143 SQL-CPU-s/100k) and the other merge-bound
importers spend the CPU in the MERGE, which the insert cap doesn't touch. ConsiderOPTION (MAXDOP n)
on the hits/Copilot/PP merge scripts and/or smaller commit batches, and rely on
ImportStartStaggerMinutesso hits + audit don't merge at the same instant on the shared SQL tier. - Off-lever notes: Sent Email (EF) is a heavy but non-spiky sustained SQL load — out of scope
for the cap but a candidate for a future EF→bulk rewrite; Usage (bulk) is already cheap. - Keep the PR framing honest: as-is it eases App-Service CPU; the SQL CPU/DTU spike — led by
hits — needs the commit-side cap (peak) and merge tuning (work).
Per-rep CSVs: loadtest-reps.csv (audit/InsertBatch/Copilot), loadtest-hits.csv (hits),
loadtest-extra.csv (Power Platform / Sent Email / Usage).
Make PR #162 ease SQL Server CPU/DTU spikes too, not just App Service CPU. Every InsertBatch importer (audit-event persistence, Copilot, Power Platform, App Insights hits) committed its staged rows through a hardcoded ParallelListProcessor(20); that fan-out is the SQL-commit burst driver and was left untouched by the audit-load throttle. Add MaxSqlCommitConcurrency (preset High=20 / Balanced=8 / Gentle=3, explicit AppSetting override) and apply it process-wide via InsertBatchConcurrency at both WebJob startups, so the SQL-commit fan-out follows the aggressiveness preset. Default Balanced => 8 (down from 20). Load test (documented on PR #162): isolated InsertBatch commit 20->8 = -19% SQL CPU and -23% wall; the merge-bound importers (hits ~143 SQL-CPU-s/100k, Copilot, Power Platform) drop their SQL peak ~30-35% at lower caps. - AppConfig: MaxSqlCommitConcurrency property + preset + explicit override. - InsertBatchConcurrency: process-wide lever consumed by InsertBatch.SaveToStagingTable. - WebJob Program.cs (both): set the lever from config at startup; log the value. - Tests: AppConfigDefaults (preset/override/invalid) + InsertBatchConcurrency clamp (58 relevant tests pass). - Tests.FakeDataGen: non-interactive LoadTestSuite load-test harness behind the numbers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make PR #162 ease SQL Server CPU/DTU spikes too, not just App Service CPU. Every InsertBatch importer (audit-event persistence, Copilot, Power Platform, App Insights hits) committed its staged rows through a hardcoded ParallelListProcessor(20); that fan-out is the SQL-commit burst driver and was left untouched by the audit-load throttle. Add MaxSqlCommitConcurrency (preset High=20 / Balanced=8 / Gentle=3, explicit AppSetting override) and apply it process-wide via InsertBatchConcurrency at both WebJob startups, so the SQL-commit fan-out follows the aggressiveness preset. Default Balanced => 8 (down from 20). Load test (documented on PR #162): isolated InsertBatch commit 20->8 = -19% SQL CPU and -23% wall; the merge-bound importers (hits ~143 SQL-CPU-s/100k, Copilot, Power Platform) drop their SQL peak ~30-35% at lower caps. - AppConfig: MaxSqlCommitConcurrency property + preset + explicit override. - InsertBatchConcurrency: process-wide lever consumed by InsertBatch.SaveToStagingTable. - WebJob Program.cs (both): set the lever from config at startup; log the value. - Tests: AppConfigDefaults (preset/override/invalid) + InsertBatchConcurrency clamp (58 relevant tests pass). - Tests.FakeDataGen: non-interactive LoadTestSuite load-test harness behind the numbers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ry fallback + force flag The previous commit added RunGraphSectionIfDueAsync but never called it, so the daily-gating the PR is named after never actually ran. This wires it up and addresses the review findings: - Wire the user-metadata / user-apps / Teams sections to the cadence gate (the headline feature now actually runs; previously the inline blocks ran every cycle). - New IImportLastRunStore with Redis + in-memory implementations, created once in Program.cs and threaded through ProgramTasks -> GraphImporter. The gate now works even when Redis is NOT configured (in-memory fallback, resets on restart) and persists across cycles when it is. Redis reads/writes are fail-open: a cache outage logs a warning and lets the import run, rather than skipping it (these imports previously had no Redis dependency at all). - ForceGraphMetadataImport flag (mirrors ForceUsageReportsImport) to force a one-off re-import without hand-deleting Redis keys. - ImportAggressiveness=High now truly restores legacy behaviour: the non-fresh Graph interval is preset-derived (High=0 every-cycle, Balanced/Gentle=24h), not a fixed 24h. - Teams gets its own GraphTeamsImportIntervalHours so its fresher channel/message analytics can be kept current without un-gating the static user-metadata/apps imports. - Gate decision extracted to ImportCadenceGate.ShouldRun (unit tested) + InMemoryImportLastRunStore round-trip tests + AppConfig tests for the new keys. Full Release build clean; 46 targeted tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make PR #162 ease SQL Server CPU/DTU spikes too, not just App Service CPU. Every InsertBatch importer (audit-event persistence, Copilot, Power Platform, App Insights hits) committed its staged rows through a hardcoded ParallelListProcessor(20); that fan-out is the SQL-commit burst driver and was left untouched by the audit-load throttle. Add MaxSqlCommitConcurrency (preset High=20 / Balanced=8 / Gentle=3, explicit AppSetting override) and apply it process-wide via InsertBatchConcurrency at both WebJob startups, so the SQL-commit fan-out follows the aggressiveness preset. Default Balanced => 8 (down from 20). Load test (documented on PR #162): isolated InsertBatch commit 20->8 = -19% SQL CPU and -23% wall; the merge-bound importers (hits ~143 SQL-CPU-s/100k, Copilot, Power Platform) drop their SQL peak ~30-35% at lower caps. - AppConfig: MaxSqlCommitConcurrency property + preset + explicit override. - InsertBatchConcurrency: process-wide lever consumed by InsertBatch.SaveToStagingTable. - WebJob Program.cs (both): set the lever from config at startup; log the value. - Tests: AppConfigDefaults (preset/override/invalid) + InsertBatchConcurrency clamp (58 relevant tests pass). - Tests.FakeDataGen: non-interactive LoadTestSuite load-test harness behind the numbers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
cb3ee70 to
269171c
Compare
Closes #161.
What & why
A customer reported the importer WebJobs cause frequent 100% CPU spikes on the shared App Service plan, tripping their admin alerts. This PR makes the importers less aggressive on CPU — on both the App Service tier and the SQL Server tier (CPU/DTU) — without losing freshness on the data that needs it most (audit events and web-traffic "hits").
Root cause
Office365ActivityImporter= audit + Graph,AppInsightsImporter= hits) plus the Web app share one App Service plan (B1 = 1 vCPU default, B2 in prod), AlwaysOn.ConsoleApp.WebjobWait(Thread.Sleep(600000)), and the two jobs are not staggered — they can peak together on the shared core.ParallelListProcessor).InsertBatchimporter (audit-event persistence, Copilot, Power Platform, App Insights hits) commits its staged rows through that same hardcoded 20-thread fan-out — the SQL-commit burst driver behind the SQL CPU/DTU spikes.GraphUsersMetadata,GraphUserAppsandGraphTeamsrun every 10-min cycle with no time gate, even though that data barely changes intraday (onlyGraphUsageReportswas already daily-gated). At ~200k users this is a major, pointless CPU burst 144x/day.What stays fresh
Audit events (
ActivityLog/Copilot) and hits keep the 10-min cycle cadence at every level — only burst concurrency (and, atGentle, the cycle pause) change. Everything else can be eased.Changes
New admin-tunable config (all optional AppSettings, preset-driven, explicit per-knob override — same read-with-default pattern as
MaxSummaryFetchConcurrency; not installer-written)ImportAggressivenessBalancedHigh|Balanced|Gentle. Drives the knobs below.MaxAuditReportLoadConcurrencyMaxSqlCommitConcurrencyInsertBatchSQL-commit thread fan-out (was hardcoded 20). Eases SQL Server CPU/DTU.ImportCyclePauseMinutesGraphMetadataImportIntervalHoursGraphTeamsImportIntervalHoursImportStartStaggerMinutesExplicit per-knob AppSettings override the preset; invalid / zero / negative values fall back to the preset.
Code
Common/Entities/Config/AppConfig.cs—ImportAggressivenessLevelenum + the 7 keys + preset/override parsing.Common/DataUtils/ParallelListProcessor.cs— optionalmaxConcurrentThreads(default 20; existing callers unchanged).Common/DataUtils/Sql/Inserts/InsertBatchConcurrency.cs(new) — process-wide SQL-commit concurrency lever (default 20, clamps< 1to 1).Common/DataUtils/Sql/Inserts/InsertBatch.cs—SaveToStagingTablefans out atInsertBatchConcurrency.MaxConcurrentThreads(the single SQL-commit choke point).Common/DataUtils/ConsoleApp.cs—WebjobWait(telemetry, minutes)(configurable cycle pause).WebJob.Office365ActivityImporter.Engine/ActivityAPI/ActivityImporter.cs— audit load usesMaxAuditReportLoadConcurrency.WebJob.Office365ActivityImporter.Engine/Graph/GraphImporter.cs+ImportLastRunStore.cs(new) — Redis-backed per-section last-run cadence gate for the non-fresh Graph imports (with an in-memory fallback when Redis is absent, and aForceGraphMetadataImportone-off bypass).WebJob.Office365ActivityImporter/Program.cs+WebJob.AppInsightsImporter/Program.cs— setInsertBatchConcurrency.MaxConcurrentThreadsfrom config at startup; configurable cycle pause; hits-WebJob startup stagger; startup log line reporting the active level and derived knobs (audit-load + SQL-commit threads, pause, gate interval).Behaviour change on upgrade
Default preset
Balanced(approved with @sambetts), so existing installs ease up automatically:Admins wanting the old behaviour set
ImportAggressiveness=High; the complaining customer can setGentle.Load test (the numbers behind the change)
Measured with a non-interactive
LoadTestSuiteharness added underTests.FakeDataGen(reproducible benchmark). Each import area imports 100k items per setting, 3 reps, median reported, sampling both importer-process CPU and LocalDBsqlservrCPU. Caveat: 8-core dev box + LocalDB (no DTU cap) != prod 1-vCPU + Azure SQL, so absolute numbers do not transfer but relative comparisons across settings do.CPU-s= work;peak %= burst (100% = one core). Full per-rep tables are in the pinned comment on this PR.SQL-commit fan-out, isolated (
InsertBatch, the new lever) — insert-bound, strict win:Real importers — SQL CPU per 100k at the current default (cap 20), showing where the DTU goes:
SaveChanges— off lever, sustained, no spikeSqlBulkCopy/ TVP)MaxAuditReportLoadConcurrencyAudit load fan-out (App Service CPU; in-memory fakes, so a lower bound): peak ~428% (cap 20) -> ~300-373% (8/3); 1-vCPU emulation pins at ~108-126% regardless, i.e. the cap bounds burst width.
Takeaways:
MaxSqlCommitConcurrency=8is a strict win on insert-bound commits (audit-event persistence, hits staging): -19% SQL CPU and -23% wall vs 20.OPTION (MAXDOP n)/ smaller batches) and lean onImportStartStaggerMinutesso hits + audit do not merge simultaneously. Sent Email (EF) is a heavy-but-non-spiky sustained load and a future EF->bulk candidate; Usage (bulk) is already cheap.Testing
AppConfigDefaultsTests— 36 tests: preset defaults for every knob (incl.MaxSqlCommitConcurrencyHigh/Balanced/Gentle = 20/8/3), case-insensitive parse, invalid/empty/zero/negative -> preset, explicit per-knob overrides,0-disables gate.ImportCadenceTests— 8 tests: the Redis-backed daily-gate for the non-fresh Graph imports.DataUtilsTests—InsertBatchConcurrency_DefaultAndClamp(default 20, clamps<1to 1) +ParallelListProcessortests (incl. chunk-exception propagation) pass with the new optional param.InsertBatchOverWidthTests+StagingColumnSqlTypeOverrideTests— the InsertBatch commit path (the line this PR changed) still skips over-width rows and emits correct staging types.GraphUsageReportImportTestsare pre-existing live-Graph integration tests that require real tenant credentials (AADSTS90002: Tenant not foundin CI/local without secrets); they are unrelated to these changes.Database changes
None. All new settings are optional appSettings; no schema/migration changes. (Labelled migration needed because it is a config change — new App Settings — per repo convention.)
Docs
Wiki updated: [Configuration Reference] gains a "Reducing importer CPU usage (aggressiveness)" section covering all knobs incl.
MaxSqlCommitConcurrency, plus a cross-link from [Monitoring System Performance].