Technical Article

Custom Replication Status Record Latency Monitor

,

Why did I write it?

I wrote this years ago as a "means to an end" when trying to determine why our daily reporting process appeared stuck.  After checking the built-in replication monitor of your transact replication solution and only seeing it was "30 seconds of latency" or 800,000 undistributed commands behind, I decided I wanted to be able to more quickly find out "which tables" were actually behind, and automate an email that would get sent stating such.  This script provides this information, as needed, and "when" needed.


What does it do?

This script compares the row counts between a specificed set of tables between the Publisher to those of the Subscriber. The built-in replication monitor, shows "number of commands in the distribution database waiting to be applied at the subscriber" This script shows which tables are behind and how many rows it needs to catch up on.


How can you use it?

For cases where real-time data is important, I've employed it's use in 2 scenarios (where our SLA of 3 minutes latency is deemed acceptable).

  • SQL Agent Jobs: In situations where a scheduled job needs to run but requires that data be current, you can add this procedure prior to the code that gets executed (or as a prior job step) and it will keep checking to ensure the record counts match before moving on.
  • Stored-procedures: Within other procedures, where data must be current, you can add this procedure prior to the code that gets executed to ensure the record counts match before moving on
Example usage:
EXEC MyDatabase.dbo.dba_CheckReplicatedTableCounts 
    @Publisher = 'Publisher', 
    @Subscriber = 'Subscriber', 
    @DB = 'MyDatabase', 
    @Tables = 'Table1,Table2,Table3,Table4,Table5,Etc', 
    @Threshold = 15, 
    @RunningFrom = 'DAILY - RUN INVOICE REPORTS', 
    @WaitTime = '00:00:03', 
    @EmailAfterLoops = 20, 
    @SuppressMsg = 0
An example of the email notification generated:
What it does NOT do:
  • It will NOT ensure that your data is accurately updated
  • It will NOT check for any in-flight data manipulations
  • It will NOT give you a raise

Known Dependencies

  1. dbo.fx_FormatArrayText() - this is a sinmple scalar function that will take a comma delimited string and format it for use in dynamic SQL.  It has been included in the SQL Scripts for this article
  2. If your replication topology involves seperate Publsher/Subscriber/Distrbutor, you will need to create a the appropriate linked servers to those instances

Known Issues

None at this time

USE [MyDatabase]
GO

SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

CREATE FUNCTION [dbo].[fx_FormatArrayText] (
	@String varchar(1500), 
	@Delimiter char(1), 
	@NumberQuotes int = 1)
RETURNS varchar(1500) AS
/*
	----------------------------------------------------------------------------------------------------------------
	Purpose: Convert delimited text within a string into parenthesized values (quotes optional)
	Department:	
	Created For: 
	----------------------------------------------------------------------------------------------------------------
	NOTES: @Delimiter - Tells function the delimiter to parse the text with
		@NumberQuotes - How many quotes you wish to have in the OUTPUT string
	----------------------------------------------------------------------------------------------------------------
	Created On: 10/20/2005
	Created By: MyDoggieJessie
	----------------------------------------------------------------------------------------------------------------
	Modified On: 
	Modified By: 
	Changes	:	
		1.	
	----------------------------------------------------------------------------------------------------------------
	SELECT dbo.fx_FormatArrayText('Table1|Table2|Table3|Table4','|', 1)
*/
BEGIN

DECLARE @Quote varchar(10)
SET @Quote = ''

/* ######################################### START MAIN FUNCTION HERE ########################################## */

IF @NumberQuotes >= 1
BEGIN
	SET @Quote = SPACE(@NumberQuotes)
	SET @Quote = REPLACE(@Quote, ' ', '''')
END

IF @Delimiter = ' '
BEGIN
	/* Eliminate double spaces in text string */
	WHILE CHARINDEX(' ', RTRIM(@String)) <> 0
	BEGIN
		SET @String = REPLACE(@String, ' ', ' ')
	END
END

ELSE
BEGIN
	/* Eliminate all spaces in text string */
	WHILE CHARINDEX(' ', RTRIM(@String)) <> 0
	BEGIN
		SET @String = REPLACE(@String, ' ', '')
	END
END

/* Convert supplied delimiter with open quotes, comma, and close quotes */
SET @String = REPLACE(@String, @Delimiter, @Quote + ',' + @Quote)

/* Add opening and closing quotes and parentheses */
SET @String = '(' + @Quote + @String + @Quote + ')'
	
/* ########################################## END MAIN END HERE ########################################### */
RETURN @String
END

GO


CREATE PROCEDURE [dbo].[dba_CheckReplicatedTableCounts] (
 	@Publisher varchar(50),
	@Subscriber varchar(50),
	@DB varchar(50), 
	@Tables varchar(750),
	@Threshold varchar(7),
	@RunningFrom varchar(250) = NULL,
	@WaitTime varchar(12) = '00:01:00',
	@EmailAfterLoops int = 20,
	@SuppressMsg tinyint = 0
) AS
/*
	--------------------------------------------------------------------------------------------------------------------------------------------
	Purpose: Monitors specific replicated tables for any publisher/subscriber for latency in record counts and emails a notification to a specific 
		list of recipients
	Department: 
	Created For: 
	--------------------------------------------------------------------------------------------------------------------------------------------
	NOTES:	<< Procedure was designed to run at the subscriber, but can be deployed to any server that has appropriate linked servers defined >>
		@Publisher is the linked server name to your Publisher
		@Subscriber is the linked server name to the Subscriber
		@Tables is the tables you want to compare records counts between the publisher/subscriber; If you want ALL REPLICATED TABLES, set this 
			parameter to NULL
		@Threshold is the record count it can be behind before triggering the email
		@RunningFrom is where this alert gets triggered from. For instance, if this was automated, you would list the SQL Agent Job Name here, if 
			it was coming from another procedure, you'd use the procedure name, etc.
		@WaitTime is the value to wait before looping to check the record counts again, no value default to 1 minute interval
		@EmailAfterLoops is what determines when to send an email. You'll receive an email after the @WaitTime * @EmailAfterLoops
			So if @WaitFor = '00:00:03' and @EmailAfterLoops = 20, you'll receive an email in 1 minute (3 x 20 = 60) - and keep receiving them
	--------------------------------------------------------------------------------------------------------------------------------------------
	DEPENDENCIES:
		dbo.fx_FormatArrayText() >> Scalar function that parses the table list to be used in dynamic SQL. For instance:
			SELECT dbo.fx_FormatArrayText('Table1|Table2|Table3|Table4','|', 1) returns:
				('Table1','Table2','Table3','Table4')
	--------------------------------------------------------------------------------------------------------------------------------------------
	Created On: 08/01/2014
	Created By: Serge Mirault
	--------------------------------------------------------------------------------------------------------------------------------------------
	Modified On: 
	Modified By: 
	Changes	:	
		1.	
	--------------------------------------------------------------------------------------------------------------------------------------------
	Example Executions:
	EXEC MyDatabase..dba_CheckReplicatedTableCounts 
		@Publisher = 'Publisher', 
		@Subscriber = 'Subscriber', 
		@DB = 'MyDatabase', 
		@Tables = 'Table1,Table2,Tabl3,Etc', 
		@Threshold = 15, 
		@RunningFrom = 'SSMS', 
		@WaitTime = '00:00:03', 
		@EmailAfterLoops = 20, 
		@SuppresMsg = 0
	
	An automated alert will be send every 1 minutes if any of the tables list have > 15 record latency	
*/
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED

DECLARE @SendExternalEmail tinyint, @To varchar(250), @Bcc varchar(250)
SELECT @SendExternalEmail  = 'dba@yourcompany.com'

DECLARE @SQL nvarchar(4000), @TempTable nvarchar(25), @Count int, @Filter1 nvarchar(850) = '', @Filter2 nvarchar(850) = '', @LoopCnt int
DECLARE @Subject varchar(250), @Body varchar(1500), @TableName varchar(50), @RowCount varchar(20), @idx int
DECLARE @Results TABLE (col1 varchar(50), col2 int, col3 varchar(50), col4 int, col5 int)

SET @LoopCnt  = 1

/* Do we have tables being passed in? */
IF (@Tables IS NOT NULL )
BEGIN
	SET @Filter1 = 'WHERE st.name IN ' + dbo.fx_FormatArrayText(@Tables, ',', 1) + ''
	SET @Filter2 = 'AND st.name IN ' + dbo.fx_FormatArrayText(@Tables, ',', 1) + ''
END
	
/* Create global temp tables based upon procedure call and for which database you have replicated; in the case below I have
	4 database published in my replication architecture */
BEGIN
	IF @DB = 'MyDatabase'
	BEGIN
		SET @TempTable = '##' + @DB + CAST(ROUND(((99999 - 2-50) * RAND() + 2), 0) AS VARCHAR(5))
		SET @SQL = 'CREATE TABLE ' + @TempTable + ' (col1 varchar(50), col2 int, col3 varchar(50), col4 int, col5 int)'
		EXECUTE Master..sp_ExecuteSQL @SQL
	END

	IF @DB = 'MyOtherDatabase'
	BEGIN
		SET @TempTable = '##' + @DB + CAST(ROUND(((99999 - 2-50) * RAND() + 2), 0) AS VARCHAR(5))
		SET @SQL = 'CREATE TABLE ' + @TempTable + ' (col1 varchar(50), col2 int, col3 varchar(50), col4 int, col5 int)'
		EXECUTE Master..sp_ExecuteSQL @SQL
	END

	IF @DB = 'MyOtherDatabaseAgain'
	BEGIN
		SET @TempTable = '##' + @DB + CAST(ROUND(((99999 - 2-50) * RAND() + 2), 0) AS VARCHAR(5))
		SET @SQL = 'CREATE TABLE ' + @TempTable + ' (col1 varchar(50), col2 int, col3 varchar(50), col4 int, col5 int)'
		EXECUTE Master..sp_ExecuteSQL @SQL
	END

	IF @DB = 'MyOtherOtherDatabase'
	BEGIN
		SET @TempTable = '##' + @DB + CAST(ROUND(((99999 - 2-50) * RAND() + 2), 0) AS VARCHAR(5))
		SET @SQL = 'CREATE TABLE ' + @TempTable + ' (col1 varchar(50), col2 int, col3 varchar(50), col4 int, col5 int)'
		EXECUTE Master..sp_ExecuteSQL @SQL
	END
END

CHECKDATA:
BEGIN
	SET @SQL = N'
	WITH Subscriber AS (
		SELECT
			sch.name AS SchemaName,
			st.Name AS TableName,
			SUM(CASE WHEN (p.index_id < 2)
						  AND (a.type = 1) THEN p.rows
					 ELSE 0
				END) AS Rows
		  FROM ' + QUOTENAME(@Subscriber) + '.' + @DB + '.sys.partitions p WITH(READUNCOMMITTED)
		  INNER JOIN ' + QUOTENAME(@Subscriber) + '.' + @DB + '.sys.allocation_units a WITH(READUNCOMMITTED)
			ON p.partition_id = a.container_id
		  INNER JOIN ' + QUOTENAME(@Subscriber) + '.' + @DB + '.sys.tables st WITH(READUNCOMMITTED)
			ON st.object_id = p.Object_ID
		  INNER JOIN ' + QUOTENAME(@Subscriber) + '.' + @DB + '.sys.schemas sch WITH(READUNCOMMITTED)
			ON sch.schema_id = st.schema_id
			' + @Filter1 + '
		  GROUP BY
			st.name,
			sch.name),
	Publisher AS (
		SELECT
			sch.name AS SchemaName,
			st.Name AS TableName,
			SUM(CASE WHEN (p.index_id < 2)
						  AND (a.type = 1) THEN p.rows
					 ELSE 0
				END) AS Rows
		  FROM ' + QUOTENAME(@Publisher) + '.' + @DB + '.sys.partitions p WITH(READUNCOMMITTED)
		  INNER JOIN ' + QUOTENAME(@Publisher) + '.' + @DB + '.sys.allocation_units a WITH(READUNCOMMITTED)
			ON p.partition_id = a.container_id
		  INNER JOIN ' + QUOTENAME(@Publisher) + '.' + @DB + '.sys.tables st WITH(READUNCOMMITTED)
			ON st.object_id = p.Object_ID
		  INNER JOIN ' + QUOTENAME(@Publisher) + '.' + @DB + '.sys.schemas sch WITH(READUNCOMMITTED)
			ON sch.schema_id = st.schema_id
		  WHERE p.rows > 0 AND st.is_published = 1
			' + @Filter2 + '
		  GROUP BY
			st.name,
			sch.name
	)

	INSERT INTO ' + @TempTable + '
	SELECT 
		s.TableName,
		s.Rows,
		p.TableName AS BTableName,
		p.Rows AS BRows,
		s.ROWS - p.Rows AS Delta
	FROM Subscriber AS s
	INNER JOIN Publisher AS p
		ON s.TableName = p.TableName
			AND s.SchemaName = p.SchemaName
			AND s.ROWS <> p.rows
			AND (s.ROWS - p.Rows) < -' + @Threshold + '
	'
	EXEC sp_executeSQL @SQL 
END

/* Capture results from the global temp table */
BEGIN
	SET @SQL = 'SELECT * FROM ' + @TempTable
	INSERT INTO @Results
		EXECUTE master..sp_ExecuteSQL @SQL
	SET @Count = @@ROWCOUNT
END

/* If > 0 we're behind, starting checking, send an email to let everyone know */
WHILE  (@Count) <> 0
BEGIN 
		SET @Subject = CAST(@@SERVERNAME as varchar(25)) + ' :: ' + @DB + ' Replication is behind for the following tables'
		DECLARE @Table TABLE (idx int IDENTITY(1,1), TableName varchar(50), [RowCount] varchar(20))
		INSERT INTO @Table
			SELECT col3, col5 FROM @Results

		SET @Body = '<p style="font-size:12px;font-family:Verdana"><font color="red">The following replcated tables are currently behind.<br>'
			+ 'The ' + ISNULL(@RunningFrom, 'reporting') + ' job will be temporarily "paused" until the threshold (' 
			+ @Threshold + ' replicated records) has been met.</font><br>' 
			+ '=========================================================================================<br>' 
		WHILE (SELECT TOP 1 idx FROM @Table) > 0
		BEGIN
			SELECT @idx = idx, @TableName = TableName, @RowCount = [RowCount] FROM @Table
			SELECT @Body = @Body  + @TableName + '  ' + @RowCount + '<br>'
		DELETE FROM @Table WHERE idx = @idx
		END

		/* Used this notification to send an alert to whoever needs to know - an example is below */
		SET @Body = @Body + '<br><br>[Some important about the process should go here], please notify someonewhocares@yourcompany.com and/or otherbigwigs@yourcompany.com IMMEDIATELY to let them know things may be falling behind</p>'		
		
		/* Send email every "X" interations through the loop (to cut down on email notifications) */
		IF @LoopCnt % @EmailAfterLoops = 0
		BEGIN
		PRINT @LoopCnt
			EXEC msdb..sp_send_dbmail @recipients = @Bcc, @Subject = @Subject, @body = @body, @body_format = 'HTML'
		END
		
WAITFOR DELAY @WaitTime

/* Clear the temp tables so we don't get stuck in a loop */
BEGIN
SET @Count = 0
	SET @SQL = 'TRUNCATE TABLE ' + @TempTable
	EXECUTE master..sp_ExecuteSQL @SQL
	DELETE FROM @Results
END

SET @LoopCnt = @LoopCnt + 1
GOTO CHECKDATA
END 

/* Clean up the global temp tables - no longer needed */
BEGIN
	SET @SQL = 'DROP TABLE ' + @TempTable
	EXECUTE master..sp_ExecuteSQL @SQL
END

IF (@Tables IS NOT NULL )
BEGIN
	IF @SuppressMsg = 0
	BEGIN
		SELECT 'Replication is NOT currently behind for the following tables: ' + @Tables + ' in the ' + @DB + ' database!'
	END
END
ELSE
BEGIN
	IF @SuppressMsg = 0
	BEGIN
		SELECT 'Replication is NOT currently behind for any replication in the ' + @DB + ' database!'
	END
END

Rate

5 (2)

You rated this post out of 5. Change rating

Share

Share

Rate

5 (2)

You rated this post out of 5. Change rating