Thursday, November 21, 2019

Rapid Query Store Growth

I recently had a problem that our Query Store size growth increased dramatically. Our initial size was 512MB and this was sufficient for 2 months. Then one day Query Store flipped to Read Only mode because it ran out of space. A quick manual expansion and flipping back to Read Write fixed the issue. I was then in the same situation within a couple days. Another quick fix had it running again and then the research into the rapid change to the growth rate started.

What I discovered is that a consultant on the application side found that in his test case the query plan for the actual parameter value was more efficient so he implemented forceliterals in Dynamics. What the unintended consequence was to eliminate plan reuse. We went from 1 plan for the query to 30,000! This was why Query Store was growing so rapidly.

What I found was that we had 51,212 different query plans. Of that total 80% of the query plans were from ONLY 4 statements!


--Find the total count of stored query plans
SELECT count(*)
FROM sys.query_store_query_text t 
JOIN sys.query_store_query q ON t.query_text_id = q.query_text_id 


 --Find the count of query plans by stemmed query text (queries not reusing plans)
select  left(txt.query_sql_text,50), count(distinct Pl.query_id) QueryCount, count(distinct Pl.plan_id) PlanCount
FROM sys.query_store_plan AS Pl  
JOIN sys.query_store_query AS Qry ON Pl.query_id = Qry.query_id  
JOIN sys.query_store_query_text AS Txt ON Qry.query_text_id = Txt.query_text_id
group by left(txt.query_sql_text,50)
having count(distinct Pl.query_id) > 1000
order by count(distinct Pl.plan_id) desc;  


Once I had the problem identified then I decided to help purge the Query Store.

First I needed a way to watch the Query Store to find if it filled and flipped to Read Only and when that happened.


--verify query store is collecting data
SELECT actual_state_desc, desired_state_desc, current_storage_size_mb,   
    max_storage_size_mb, readonly_reason, interval_length_minutes,   
    stale_query_threshold_days, size_based_cleanup_mode_desc,   
    query_capture_mode_desc  
FROM sys.database_query_store_options;  


--when did query store flip to readonly --time is in UTC
select top 1 last_execution_time 
from sys.query_store_query 
order by last_execution_time desc


Next, since the 4 queries accounted for 80% of my plans I decided to purge these regularly. I am using a SQL Job purging these queries every 10 minutes as the cursor for the delete is a bit slow.

--remove specific queries that are not reusing query plans
DECLARE @query_sql_text TABLE (sql_text varchar(100), QueryCount INT, PlanCount INT);
declare @query_sql_text_state varchar(100);

--count of query plans by stemmed query text
select  left(txt.query_sql_text,50), count(distinct Pl.query_id) QueryCount, count(distinct Pl.plan_id) PlanCount
FROM sys.query_store_plan AS Pl  
JOIN sys.query_store_query AS Qry ON Pl.query_id = Qry.query_id  
JOIN sys.query_store_query_text AS Txt ON Qry.query_text_id = Txt.query_text_id
group by left(txt.query_sql_text,50)
having count(distinct Pl.query_id) > 1000
order by count(distinct Pl.plan_id) desc;  

--loop through all query plans in @query_sql_text
While (Select Count(*) From @query_sql_text) > 0
Begin

Select Top 1 @query_sql_text_state = sql_text From @query_sql_text order by sql_text asc
print @query_sql_text_state

 --remove the query_ids from query store
 DECLARE @id int
 DECLARE adhoc_queries_cursor CURSOR
 FOR
  select top 100  Pl.query_id
  FROM sys.query_store_plan AS Pl  
  JOIN sys.query_store_query AS Qry ON Pl.query_id = Qry.query_id  
  JOIN sys.query_store_query_text AS Txt ON Qry.query_text_id = Txt.query_text_id
  where left(trim(txt.query_sql_text),50) = @query_sql_text_state 
  group by Pl.query_id
  order by min(Qry.last_execution_time) asc

 OPEN adhoc_queries_cursor;
 FETCH NEXT FROM adhoc_queries_cursor INTO @id;
 WHILE @@fetch_status = 0
  BEGIN
   PRINT @id
   EXEC sp_query_store_remove_query @id
   FETCH NEXT FROM adhoc_queries_cursor INTO @id
  END
 CLOSE adhoc_queries_cursor;
 DEALLOCATE adhoc_queries_cursor;

delete Top (1) From @query_sql_text where sql_text = @query_sql_text_state

End

Now that Query Store size is better managed and I can get back to tuning the poor performing queries.

Thursday, November 14, 2019

Maximize Resumable Indexes

Resumable Indexes were a new feature introduced with SQL 2017. At first glance it didn't make sense why I would want to modify our maintenance plans to include resumable indexes because everything was running fine. This was one of those instances of we don't need resumable index rebuilds until we needed them.

We are running Microsoft Dynamics 4.0 that was released in 2007. This likely isn't the oldest software that you have seen being band-aided to stay in production, but it did not like running on SQL 2017. We had recently upgraded our database from 2008 to 2017 (that is an adventure for another day) and performance was lagging. Dynamics was making poor choices for query plans and I found that we needed to keep our index fragmentation much tighter than we previously had. Any fragmentation above 5% could potentially cause issues the query plan that Dynamics chose. Dynamics seemed to be choosing the query plans independently of SQL query engine to use in its overuse of cursors.

With a total database nearing 4TB of primarily compressed tables, tables approaching 1 billion rows, and tables with more data in indexes than data in the table; there just wasn't time in the day to rebuild all the indexes.

This is where the resumable index rebuilds came into save us. Resumable index rebuilds do just what they sound like, allow you to start a rebuild and pause it for whatever reason and then continue the rebuild later. You do not lose the time spent rebuilding indexes if you need to kill the rebuild process because of blocking against your production workload! When you combine this with the WAIT_AT_LOW_PRIORITY option you can run the rebuild and not babysit for interruptions to production!

Here is the basic code for rebuilding the index with a auto kill feature so you don't bring down production.


--Author:      Todd Palecek 
--Description: Rebuild indexes with a resumable and auto-kill method

--This will start the index rebuild and run for up to 35 minutes. If the rebuild is blocking it will
--only block for 1 minute before killing itself. 
ALTER INDEX [I_177DIMIDIDX] ON [dbo].[INVENTTRANS]
REBUILD WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY ( MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF ))
,RESUMABLE = ON,MAX_DURATION =35 MINUTES, maxdop=8);
GO


Now if your rebuild is causing blocking and dies you will need to restart it. This frequently happens when SQL is switching in the rebuild index for me.



--resume the rebuild for another x minutes and then pause
ALTER INDEX I_366MKCCUSTSALESSTATUS ON [dbo].SALESTABLE  Resume WITH (MAX_DURATION =1 MINUTES)
GO


A nice benefit of resumable indexes that the percent complete will show up in sp_whoisactive. You can also monitor progress here.



--monitor percent complete and duration
SELECT total_execution_time, percent_complete, name,state_desc
,last_pause_time,page_count
FROM sys.index_resumable_operations;

Friday, October 18, 2019

R Market Basket Analysis for Product Recommendations

After attending the Data Science Dojo boot camp I was looking for projects to give a quick return on the investment in training. Since there are reports that Amazon generates about 30% of its revenue from product recommendations I followed their lead and built our own recommendation engine.

This is the first piece to pull in the data and measure the relationships and write it back out to your database. The supporting pieces will be coming later on building the database and query to return the recommended products to use. Also coming up is some website scraping in Python to top selling products from others and how to include them to boost your recommendations.


#Author:      Todd Palecek (toddpalecek@gmail.com)
#Description: Determine Product Affinity from a Market Basket
#             using Apriori to feed a SQL Recommendation Engine


library(RODBC)
library(stats)
library(reshape2)
library(arules)
library(arulesViz)
library(config)


#Definitions:
#Support: The fraction of which our item set occurs in our dataset.
#Confidence: probability that a rule is correct for a new transaction with items on the left.
#Lift: The ratio by which by the confidence of a rule exceeds the expected confidence. 
#Note: if the lift is 1 it indicates that the items on the left and right are independent.


#variable for report name and key to write to SQL
var.RptType <- 'MktBskt-60'
var.Link <- 'DC1-DWDB1.Silvermine.dbo.DimProduct.SKU'  


#memory cleanup - may not be needed but can't hurt
gc()

#check and increase memory limit
memory.limit()
memory.limit(size = 26000) #if lower than currently set will not decrease memory available


#import from SQL
cn2 <- odbcDriverConnect(connection="Driver={SQL Server Native Client 11.0};server=YourServer;database=DataWarehouse;trusted_connection=yes;")
cnWrite <- odbcDriverConnect(connection="Driver={SQL Server Native Client 11.0};server=YourServer;database=Grok;trusted_connection=yes;case=nochange")


#Import data to R from SQL Data Warehouse
#To include text in SQL statement need to format as \'text\'
#If using your data warehouse as source date is likely an integer
#Only including orders that have multiple items to limit data imported to only useful data
dataFetch2 <-sqlQuery(cn2, 'with OrderItems (SKU, OrderID,BrandKey,OrderMethodKey)
as
                      (SELECT distinct P.SKU, D.OrderID, D.BrandKey,D.OrderMethodKey
                      FROM [Silvermine].[ssas].[FactDemand] D 
                      join [Silvermine].[ssas].[DimProduct] P on D.ProductKey = P.ProductKey 
                      where  D.OrderReceivedDateKey between (SELECT YEAR(GETDATE()-60) * 10000 + MONTH(GETDATE()-60) * 100 + DAY(GETDATE()-60)) 
                      and (SELECT YEAR(GETDATE()-1) * 10000 + MONTH(GETDATE()-1) * 100 + DAY(GETDATE()-1))
                      )
                      select O.OrderID, O.SKU, O.BrandKey, O.OrderMethodKey
                      from OrderItems O
                      where O.BrandKey in (1) and O.OrderID in (select OrderID from OrderItems group by OrderID having COUNT(SKU) > 1)', rows_at_time=1000)


#close connection after not needed to free resources
close(cn2)

#memory cleanup - may not be needed but can't hurt
gc()


#copy to new dataframe for modifications
dataFetch3 <- dataFetch2


#extract unique values of SKUs and Order Methods to loop through later
sku_list <- unique(dataFetch3$BrandKey)
ordermethod_list <- unique(dataFetch3$OrderMethodKey)


#create blank data frame to populated relationships into
rulestoDBALL <- data.frame()


#loop through each SKU, Brand, and Order Method to build relationships
for (i in 1:length(sku_list)){
  dataFetch2 <- dataFetch3[which(dataFetch3$BrandKey==sku_list[i]),]
  dataFetch2$BrandKey <- NULL
  for (j in 1:length(ordermethod_list)){
    dataFetch4 <- dataFetch2[which(dataFetch2$OrderMethodKey==ordermethod_list[j]),]
    dataFetch4$OrderMethodKey <- NULL
    t <- dcast(dataFetch4, OrderID ~ SKU, drop = FALSE) #added drop = FALSE becasue of errors
    t[is.na(t)] <- 0
    
    gc()
    
    t1 <- as(t[,-1]>0,"transactions")
    
    #Get the rules, maxlen sets number of items in set - default is 10 - the more returned the 
    #more resource intenstive analysis will be. maxlen = 2 returns the top related products that
    #we will recommend and speeds processing of data
    rules <- apriori(t1, parameter = list(supp = 0.00001, conf = 0.05,maxlen=2))
    
    #Show only 2 digits
    options(digits=2)
    
    #convert rules to data frame in prep to write to DB
    rulestoDB <- as(rules, "data.frame");
    rulestoDB$brandkey <- (sku_list[i]) 
    rulestoDB$Link <- var.Link 
    rulestoDB$OrderMethod <- (ordermethod_list[j])
    rulestoDBALL <- rbind(rulestoDBALL,rulestoDB)
  }
}


#optional: review the rules created
inspect(rules[1:5])


#variable for report name and key to write to SQL
rulestoDBALL$AnalysisType <- print(var.RptType) 
rulestoDBALL$AnalysisDate <- as.Date(Sys.Date())
rulestoDBALL$ReportType <- print(var.RptType) 
rulestoDBALL$ReportDate <- as.Date(Sys.Date())


#Write data to SQL table. Further analysis is done within SQL.
sqlSave(cnWrite,rulestoDBALL,tablename = "stgR_MarketBasket",rownames=FALSE,append = TRUE, fast = FALSE )


#further reading
#http://www.salemmarafi.com/code/market-basket-analysis-with-r/

#Supporting SQL Tables
# USE [Grok]
# GO
# 
# SET ANSI_NULLS ON
# GO
# 
# SET QUOTED_IDENTIFIER ON
# GO
# 
# CREATE TABLE [dbo].[stgR_MarketBasket](
#   [rules] [varchar](250) NULL,
#   [support] [decimal](8, 7) NULL,
#   [confidence] [decimal](5, 4) NULL,
#   [lift] [int] NULL,
#   [brandkey] [int] NULL,
#   [Link] [varchar](250) NULL,
#   [OrderMethod] [int] NULL,
#   [AnalysisType] [varchar](50) NULL,
#   [AnalysisDate] [date] NULL,
#   [ReportType] [varchar](50) NULL,
#   [ReportDate] [date] NULL
# ) ON [PRIMARY]
# GO