Can anyone help me understand what's happening here....
I'm trying to get consistent performance from a query being called from a third-party application. Changing the query itself isn't an option. The full query is shared at the bottom
(last minute addition to the post - see the note about the cardinality estimator in BOLD)
The query is pretty ugly and complex. The key part for my problem is caused by the date filter.
It runs in about 1 second like this:
AND [r1].[Setup_Date] BETWEEN '20250820' AND '20250910'
But nearly a minute with ~25% increase in the range, like this:
AND [r1].[Setup_Date] BETWEEN '20250820' AND '20250917'
This is the 'good' plan: https://www.brentozar.com/pastetheplan/?id=JzaR1eYhHI
This is the 'bad' plan: https://www.brentozar.com/pastetheplan/?id=vkcJKorUuw
Both 'REG0001' and 'REG0002' are heaps.
The bad plan is doing an index seek on REG0002 but SQL Server is estimating to do it only once and choosing a nested loop. However, it actually executes it several thousand times. Whereas the good plan is scanning the table once and performing a hash match. If I hint the query to force a hash match, it fixes the issue, but changing the code isn't an option in this case.
This index being seeked in the bad plan looks like this:
CREATE UNIQUE NONCLUSTERED INDEX [REG0002003] ON [dbo].[REG0002]
(
[STATUS] ASC,
[AGREEMENT_NO] ASC,
[PAYMENT_NO] ASC
)
There is a statistic on SETUP_DATE that is up to date. You can see from the statistics that the values are roughly a week apart. The value highlighted shows where performance falls of a cliff.
I did consider forcing the 'good' plan with query store. However, the query isn't parameterized.
I'm really struggling to explain this behaviour and the importance of the small change in date range to the query plan. It's SQL Server 2022 (150). Same problem occurs in 2019 (150). HOWEVER - If I turn LEGACY_CARDINALITY_ESTIMATOR ON, this fixes it in 2019. I haven't tried it in SQL 2022 yet as it's a prod system.
I've provided everything I can think of but if you need more info, please just let me know.
SELECT 32954,
274252,
ROW_NUMBER() OVER (ORDER BY CASE
WHEN "Permissions"."Donor" IS NULL THEN
'Z'
ELSE
'Y'
END,
"Don0001"."Name",
"Don0001"."PCSortReference",
"Don0001"."Donor_No",
"r1"."Agreement_No"
),
"r1"."recnum",
CASE
WHEN "Permissions"."Donor" IS NULL THEN
'Z'
ELSE
'Y'
END,
"Don0001"."Name",
"Dt0004"."Description",
CASE "r1"."Status"
WHEN '1' THEN
'Current'
WHEN '2' THEN
'Complete'
WHEN '3' THEN
('Terminated ' + CONVERT(CHAR(10), "r1"."Terminated_On", 103))
ELSE
''
END,
ISNULL([r2a].[NoPayments], 0),
ISNULL([r2d].[NoOverDue], 0),
ISNULL([r2a].[Amount], 0),
ISNULL([r2d].[OverDueTotal], 0),
"r1"."Agreement_No",
"r1"."Amount",
"r1"."Start_Date",
"r1"."Next_Date",
'1753-01-01',
"r1"."Term",
CAST("r1"."Payments_Year" AS INT) * CAST("r1"."Term" AS INT)
FROM "Reg0001" AS "r1"
LEFT JOIN "Don0001" AS "Permissions"
ON "Permissions"."Donor" = "r1"."Donor"
AND "Permissions"."Type" IN ( 'C', '1', 'E', 'F', 'T', 'I', '2', 'S', 'O', '3', '0', 'P', 'Z', 'V' )
LEFT JOIN "Don0001"
ON "Don0001"."Donor" = "r1"."Donor"
LEFT JOIN "Dt0004"
ON "Dt0004"."Payments_Year" = "r1"."Payments_Year"
LEFT JOIN
(
SELECT MIN("r1"."recnum") AS [recnum],
COUNT("r2"."amount") AS [NoPayments],
SUM("r2"."amount") AS [Amount]
FROM "Reg0002" AS "r2"
INNER JOIN "Reg0001" AS "r1"
ON "r2"."Agreement_No" = "r1"."Agreement_No"
GROUP BY "r2"."Agreement_No"
) AS "r2a"
ON "r2a"."recnum" = "r1"."recnum"
LEFT JOIN
(
SELECT MIN("r1"."recnum") AS [recnum],
COUNT("r2"."amount") AS [NoPaymentsMade],
SUM("r2"."amount") AS [TotalMade]
FROM "Reg0002" AS "r2"
INNER JOIN "Reg0001" AS "r1"
ON "r2"."Agreement_No" = "r1"."Agreement_No"
WHERE [r2].[Status] = '2'
GROUP BY "r2"."Agreement_No"
) AS "r2b"
ON "r2b"."recnum" = "r1"."recnum"
LEFT JOIN
(
SELECT MIN("r1"."recnum") AS [recnum],
COUNT("r2"."amount") AS [ConsecPayments],
SUM("r2"."amount") AS [ConsecTotal]
FROM "Reg0002" AS "r2"
LEFT JOIN
(
SELECT MIN("r2"."Agreement_No") AS [Agreement_No],
MAX("r2"."Payment_No") AS [MissedPayment]
FROM "Reg0002" AS "r2"
WHERE "r2"."Status" = 'M'
GROUP BY "r2"."Agreement_No"
) AS "M"
ON "r2"."Agreement_No" = [M].[Agreement_No]
INNER JOIN "Reg0001" AS "r1"
ON "r2"."Agreement_No" = "r1"."Agreement_No"
WHERE "r2"."Status" = '2'
AND "r2"."Payment_No" > (ISNULL([M].[MissedPayment], 0))
GROUP BY "r2"."Agreement_No"
) AS "r2c"
ON "r2c"."recnum" = "r1"."recnum"
LEFT JOIN
(
SELECT MIN("r1"."recnum") AS [recnum],
COUNT("r2"."amount") AS [NoOverDue],
SUM("r2"."amount") AS [OverDueTotal]
FROM "Reg0002" AS "r2"
INNER JOIN "Reg0001" AS "r1"
ON "r2"."Agreement_No" = "r1"."Agreement_No"
WHERE "r2"."Date_Paid" = '1753-01-01'
AND "r2"."Date_Due" <= '20251014'
AND "r2"."Status" = '1'
GROUP BY "r2"."Agreement_No"
) AS "r2d"
ON "r2d"."recnum" = "r1"."recnum"
WHERE [r1].[Status] != '2'
AND [r1].[Status] != '3'
AND [Don0001].[Anonymised] <> 'Y'
AND [r1].[Setup_Date] BETWEEN '20250820' AND '20250917'
