[DBOPS-231] Rewrite SecurityTask_ReadByUserIdStatus to remove OPTION (RECOMPILE) - #8331
[DBOPS-231] Rewrite SecurityTask_ReadByUserIdStatus to remove OPTION (RECOMPILE)#8331rkac-bw wants to merge 2 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8331 +/- ##
==========================================
+ Coverage 64.03% 69.70% +5.66%
==========================================
Files 2473 2473
Lines 106017 106017
Branches 9613 9613
==========================================
+ Hits 67885 73894 +6009
+ Misses 35772 29641 -6131
- Partials 2360 2482 +122 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Holding this draft — please don't review yet. Further measurement on a production-shaped restore found that the main SELECT's cached plan is sensitive to the size of the staged collection set belonging to whichever caller compiles it. The I'm revising the procedure to remove that sensitivity and will force-push once the replacement is measured across compile orders. The parameters, projected columns and result ordering will not change. |
1eefbef to
4a658a6
Compare
|
Revised and ready for review. The instability I flagged above is fixed, and without adding a query hint. The set that decides the plan shape is now held in a Why that works. The access probe has two possible shapes: seek Measured on a production-shaped restore, 13 access shapes, index in its production form:
The obvious objection to a temp table is that it trades per-call recompilation for statistics-driven recompilation — so that was measured too. Rotating all 13 shapes six times: 8 recompiles in 78 calls at 16.3 ms CPU per call, against the current body's one recompile per call at 60.7 ms. The thresholds are the documented ones for temp tables (6 rows, then 500, then 500 + 20%), so callers within a band share a plan. The table is declared with an unnamed inline primary key so SQL Server can cache it between executions. Result parity re-verified against the current body over the 13 shapes x 3 status values: 39 pairs, 11,720 rows, zero differences. |
4a658a6 to
d87b93c
Compare
|
Added the covering index, so this is now three files.
Measured across the same 13 access shapes:
About 31% overall, and the no-access shapes — members of a large task organization who cannot edit anything, a very common caller — improve by 78x and 47x. Worth being explicit that the two changes are independent: the procedure is plan-stable without the index (0.09% across six compile orders) and equally stable with it (0.02%). The index is a straight reduction in reads, not a correctness or stability crutch. No EF change. |
d87b93c to
9903866
Compare
…(RECOMPILE) The hint was added in PM-21044 because the previous body's cost depended on the caller's organization size, so a single cached plan could not serve every caller. Compiling on every execution is expensive in CPU, and each compile also takes schema-stability locks across the referential-integrity closure of the tables in the query. This removes the reason the plan had to vary rather than just dropping the hint: the caller's confirmed memberships in enabled organizations are staged first, the editable-collection set is staged only when those organizations have tasks, and callers with no editable collections are routed to their own trivial statement so they cannot cache a plan for the main query. The editable-collection set is held in a #temp table rather than a table variable. That set's cardinality decides which of two very differently priced plan shapes the optimizer picks for the access probe, and a table variable has no statistics: under deferred compilation the first caller's row count is baked into the cached plan for everyone. Measured on a production-shaped restore, that made the heaviest caller swing between 28k and 128k logical reads depending only on who compiled the plan. With a table with statistics, six different compile orders stayed within 0.09%. IX_SecurityTask_OrganizationId is rebuilt with an INCLUDE list covering the columns the procedure returns, removing a clustered-index lookup per row. A DROP_EXISTING rebuild of an existing index on a ~17,600 row table. Its previous filter was redundant on a NOT NULL column, and the EF model has always declared this index unfiltered, so dropping it aligns the two tracks rather than diverging them. Parameters, projected columns and their order, and the result ordering are all unchanged, so no repository, EF or _V2 change is required. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9903866 to
e85f7b7
Compare
|
Force-pushed a whitespace-only revision ( No statement changed; the token stream is identical to the previous head. Re-gated against a freshly migrated database: DACPAC DeployReport clean, migration naming check passes, SecurityTask integration tests 9/9. |
shane-melton
left a comment
There was a problem hiding this comment.
Vault's business logic looks to behave the same and is good for merge.
| -- Why a #temp table and not a table variable | ||
| -- ------------------------------------------ | ||
| -- This set drives the access probe in step 3b, and how many rows are in it decides which of | ||
| -- two very different plans the optimizer picks (see the comment there). A table variable has | ||
| -- no statistics: under deferred compilation SQL Server takes the FIRST caller's row count and | ||
| -- bakes it into the cached plan for everyone. A caller with one collection would therefore pin | ||
| -- a plan that costs a caller with 173 collections roughly 128k logical reads instead of 28k -- | ||
| -- worse, for that caller, than the OPTION (RECOMPILE) body this replaces. A #temp table | ||
| -- carries real statistics, so each caller's plan reflects the set actually in front of it. | ||
| -- | ||
| -- That does mean occasional statement recompiles when the row count moves, which is the thing | ||
| -- this change exists to avoid -- so it was measured rather than assumed. Rotating all 13 test | ||
| -- shapes six times: 8 recompiles in 78 calls, and 16.3 ms CPU per call, against the previous | ||
| -- body's one recompile per call and 60.7 ms. The table is declared with an unnamed inline | ||
| -- primary key so SQL Server can cache it between executions; naming that constraint would | ||
| -- disable the cache and add tempdb allocation to every call. | ||
| -- ------------------------------------------------------------------------------------------ |
There was a problem hiding this comment.
👍 These explanations are very much appreciated!
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the rewritten A few things checked and found not to be problems, recorded so they are not re-raised: removing |
🎟️ Tracking
DBOPS-231
📔 Objective
SecurityTask_ReadByUserIdStatuscarriesOPTION (RECOMPILE), added in PM-21044 (#5779) alongside the four-CTE body it still has today. The hint makes the procedure compile on every execution, and for this procedure compilation is the dominant cost — it outweighs actual execution by well over an order of magnitude. Each compile also takes schema-stability locks across the referential-integrity closure of the tables in the query, which puts a very frequently called read in the way of schema changes on those tables.Deleting the hint on its own is not equivalent. The current body's cost depends on the caller's organization size, so with a cached plan whichever caller compiles first imposes their shape on everyone else; removing only the hint measurably regressed some callers under test.
This PR removes the reason the plan had to vary:
EXISTSprobe per task against the small staged set.Because every step is driven by a small staged set, a single cached plan is correct for all callers and the hint is no longer needed. The body is heavily commented: each step explains what it does and why it is shaped that way, including why the empty-set routing exists and why the
UNIONis load-bearing.Unchanged: parameters, the seven projected columns and their order, and the result ordering (
CreationDate DESC). No repository, EF Core or_V2change is required — this is a backwards-compatible stored-procedure modification.Validation: result equivalence was verified against the current body on a restore of production, comparing full result sets in both directions. The test population was built to exercise every access shape on purpose — heavy direct members, group-only members, read-only members, members with no access at all, disabled organizations, invited/accepted/revoked memberships, and users with no organization. Zero row or column differences across 36,185 paired calls covering 224,545 rows. Plan stability was separately confirmed across four different compile orders.
Out of scope, tracked separately:
SecurityTaskthat pairs with this body — deliberately not bundled here.CollectionUserrow exists on the same collection). Not introduced or changed by this PR./cc @bitwarden/team-data-insights-and-reporting-dev — Security Tasks is your feature area, so I would like your eyes on the access semantics. CODEOWNERS routes these paths to Vault and DBOps, so you would not otherwise be requested automatically.