Showing posts with label Partitioning. Show all posts
Showing posts with label Partitioning. Show all posts

Monday, April 8, 2013

Get partition bounds and number of rows

Sometimes, you need to have a synthetic view of the partition usage of a table.
For a specific table, the following query returns for each non-empty partition:
  • partition number
  • associated filegroup
  • number of rows of the partition
  • partition bounds (including RIGHT or LEFT inclusion)
  • number of rows in the partitions versus number of rows of the whole table (as a percentage)

SELECT
 '[' + s.name + '].[' + t.name + ']' AS TableName,
 p.partition_number AS PartitionNumber,
 CAST(prv_left.value AS VARCHAR)
 + CASE pf.boundary_value_on_right WHEN 1 THEN ' <= X < ' ELSE ' < X <= ' END
 + CAST(prv_right.value AS VARCHAR) AS PartitionBounds,
 p.[rows] AS PartitionNumberOfRows,
 fg.name AS PartitionFilegroup,
 CAST((100 * p.[rows]) / ii.[rows] AS VARCHAR) + '%' AS SizeRatio
FROM sys.tables t
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
INNER JOIN sys.indexes i ON i.object_id = t.object_id
INNER JOIN sysindexes ii ON ii.id = t.object_id
INNER JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id = i.index_id
INNER JOIN sys.partition_schemes ps ON ps.data_space_id = i.data_space_id
INNER JOIN sys.partition_functions pf ON pf.function_id = ps.function_id
INNER JOIN sys.allocation_units au ON au.container_id = p.hobt_id
INNER JOIN sys.filegroups fg ON fg.data_space_id = au.data_space_id
LEFT JOIN sys.partition_range_values prv_left
 ON prv_left.function_id = pf.function_id
 AND prv_left.boundary_id = p.partition_number - 1
LEFT JOIN sys.partition_range_values prv_right
 ON prv_right.function_id = pf.function_id
 AND prv_right.boundary_id = p.partition_number
WHERE i.[type] IN (0, 1) /* Index of type HEAP or CLUSTERED */
AND s.name = 'your_schema_name'
AND t.name = 'your_table_name'
AND p.[rows] > 0
AND ii.indid IN (0, 1)
ORDER BY s.name, t.name, prv_right.value


This may be useful to detect space-consuming partitions, to ensure partition distribution is homogenous, to ensure filegroups are correctly mapped over the partition bounds, etc.

Duplicate table structure and indexes for switch partition

When you want to perform a SWITCH PARTITION statement, you need to duplicate the table structure you are switching. See this article for more details about partitioning.
As far as I know, there is no such a native capability in SQL Server. You may try
SELECT * INTO <your_switch_table> FROM <your_original_table>

But this will only copy the list of columns, not the indexes that are needed for partition switch. What is more, the table will be created in default filegroup, where you would prefer the partitioned filegroup.

So I wrote a stored procedure that creates a copy of the table structure, indexes (clustered an non-clustered), and allows some options for the storage filegroup.


/*
This stored procedure generates a SQL script that creates a copy of SQL
table and indexes. Some options allow to fine-tune the generated script,
like include indexes or not, change the filegroup of table, etc.
*/
CREATE PROCEDURE SCRIPT_CREATE_TABLE
 @OriginalSchemaName SYSNAME,
 @OriginalTableName SYSNAME,
 @TargetSchemaName SYSNAME,
 @TargetTableName SYSNAME,
 @OutputQuery VARCHAR(MAX) OUTPUT,
 @FilegroupToUse SYSNAME = NULL,
 @ScriptIndexes BIT = 1
AS
BEGIN

/*
 References
 http://stackoverflow.com/questions/21547/in-sql-server-how-do-i-generate-a-create-table-statement-for-a-given-table
 http://www.sqlteam.com/forums/topic.asp?TOPIC_ID=125819
 http://ranjithk.com/2010/01/31/how-to-determine-the-file-group-of-a-table-or-a-index/
 http://stackoverflow.com/questions/1121522/generate-create-scripts-for-a-list-of-indexes
*/

BEGIN TRY


/* ******************************** */
/* FIND THE OBJECT_ID CORRESPONDING */
/* TO THE ORIGINAL TABLE            */
/* ******************************** */
SET @OutputQuery = ''
DECLARE @Msg VARCHAR(MAX)
DECLARE @TableId INT = NULL

SELECT @TableId = t.object_id
FROM sys.tables t
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
INNER JOIN sys.columns c ON c.object_id = t.object_id
WHERE s.name = @OriginalSchemaName
AND t.name = @OriginalTableName

IF @TableId IS NULL
 BEGIN
 SET @Msg = 'Cannot find table [' + @OriginalSchemaName + '].[' + @OriginalTableName + ']'
 RAISERROR(@Msg, 16, 1)
 END




/* ************************************* */
/* FIND THE FILEGROUP / PARTITION SCHEME */
/* TO BE USED IN TARGET TABLE            */
/* ************************************* */

DECLARE @TargetFilegroup VARCHAR(MAX) = NULL
IF @FilegroupToUse IS NULL OR @FilegroupToUse = ''
 /* No filegroup specified, find the original one */
 BEGIN
 
 DECLARE @OriginalFilegroup VARCHAR(MAX) = NULL
 
 /* First, try to find a partition scheme used by the table */
 SELECT @OriginalFilegroup = '[' + ps.name + ']([' + c.name + '])'
 FROM sys.tables t
 INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
 INNER JOIN sys.indexes i ON i.object_id = t.object_id
 INNER JOIN sys.partition_schemes ps ON ps.data_space_id = i.data_space_id
 INNER JOIN sys.partition_functions pf ON pf.function_id = ps.function_id
 INNER JOIN sys.index_columns ic ON ic.partition_ordinal > 0 AND ic.index_id = i.index_id AND ic.object_id = t.object_id
 INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
 WHERE i.[type] IN (0, 1) /* Index of type HEAP or CLUSTERED */
 AND s.name = @OriginalSchemaName
 AND t.name = @OriginalTableName
 
 /* If no partition shceme found, try to find a regular filegroup */
 IF @OriginalFilegroup IS NULL
  BEGIN
  SELECT @OriginalFilegroup = '[' + d.name + ']'
  FROM sys.filegroups d
  INNER JOIN sys.indexes i ON i.data_space_id = d.data_space_id
  INNER JOIN sys.tables t ON t.object_id = i.object_id
  WHERE i.index_id < 2
  AND t.name = @OriginalTableName
  AND t.schema_id = schema_id(@OriginalSchemaName)
  END

 /* No regular filegroup neither partition shceme found, there is a problem */
 IF @OriginalFilegroup IS NULL
  BEGIN
  SET @Msg = 'Could not find filegroup neither partition scheme for table [' + @OriginalSchemaName + '].[' + @OriginalTableName + ']'
  RAISERROR(@Msg, 16, 1)
  END
  
 SET @TargetFilegroup = @OriginalFilegroup
 
 END
ELSE
 /* A filegroup has been specified, just use this one */
 BEGIN
 SET @TargetFilegroup = @FilegroupToUse
 END




/* ******************************* */
/* GENERATE CREATE TABLE STATEMENT */
/* ******************************* */

DECLARE @SqlQuery_CreateTable VARCHAR(MAX)
SELECT  @SqlQuery_CreateTable = 'CREATE TABLE [' + @TargetSchemaName + '].[' + @TargetTableName + ']
(
' + o.list +
') ON ' + @TargetFilegroup + '
WITH ( DATA_COMPRESSION = ' + CompressionMode + ')
' + CASE WHEN tc.Constraint_Name IS NULL THEN '' ELSE 'ALTER TABLE [' + @TargetSchemaName + '].[' + @TargetTableName + '] ADD CONSTRAINT ' + tc.Constraint_Name  + ' PRIMARY KEY (' + LEFT(j.List, Len(j.List)-1) + ')' END
FROM sysobjects so
INNER JOIN
(
 SELECT p.object_id, MIN(data_compression_desc) AS CompressionMode
 FROM sys.partitions p
 WHERE p.object_id = @TableId
 AND p.index_id < 2
 GROUP BY p.object_id
) p ON p.object_id = so.id
CROSS APPLY
(
 SELECT '  [' + column_name + '] ' + data_type + 
  CASE data_type
            WHEN 'sql_variant' THEN ''
            WHEN 'text' THEN ''
            WHEN 'decimal' THEN '(' + cast(numeric_precision_radix AS VARCHAR) + ', ' + CAST(numeric_scale AS VARCHAR) + ')'
            ELSE COALESCE('('+ CASE WHEN character_maximum_length = -1 THEN 'MAX' ELSE CAST(character_maximum_length AS VARCHAR) END +')', '')
   END + ' ' +
        CASE
   WHEN EXISTS
    ( 
     SELECT id FROM syscolumns
     WHERE object_name(id) = so.name
     AND name = column_name
     AND columnproperty(id,name, 'IsIdentity') = 1 
    )
    THEN 'IDENTITY(' + CAST(IDENT_SEED(so.name) AS VARCHAR) + ', ' + CAST(IDENT_INCR(so.name) AS VARCHAR) + ') '
   ELSE ''
   END +
         (CASE WHEN IS_NULLABLE = 'No' THEN 'NOT ' ELSE '' END ) + 'NULL' + 
          CASE WHEN information_schema.columns.COLUMN_DEFAULT IS NOT NULL THEN ' DEFAULT '+ information_schema.columns.COLUMN_DEFAULT ELSE '' END + ',' + CHAR(10)
     FROM information_schema.columns
     WHERE table_name = so.name
     ORDER BY ordinal_position
     FOR XML PATH('')
) o (list)
LEFT JOIN information_schema.table_constraints tc
 ON tc.Table_name = so.Name AND tc.Constraint_Type = 'PRIMARY KEY'
CROSS APPLY
(
 SELECT '[' + Column_Name + '], '
 FROM information_schema.key_column_usage kcu
 WHERE kcu.Constraint_Name = tc.Constraint_Name
 ORDER BY ORDINAL_POSITION
 FOR XML PATH('')
) j (list)
WHERE so.xtype = 'U'
AND so.name NOT IN ('dtproperties')
AND so.id = @TableId

SET @OutputQuery = @OutputQuery + @SqlQuery_CreateTable



/* ******************************** */
/* GENERATE CREATE INDEX STATEMENTS */
/* ******************************** */
IF @ScriptIndexes = 1
 BEGIN
 DECLARE @SqlQuery_CreateIndex VARCHAR(MAX)
 ;
 WITH IndexCTE AS
 (
  SELECT DISTINCT
   i.index_id,
   i.name,
   i.object_id,
   p.[data_compression_desc] COLLATE SQL_Latin1_General_CP1_CI_AS AS CompressionMode,
   ifg.IndexFilegroup,
   i.is_unique
  FROM sys.indexes i
  INNER JOIN sys.tables t ON t.object_id = i.object_id
  INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
  INNER JOIN sys.index_columns ic ON i.index_id = ic.index_id AND i.object_id = ic.object_id
  INNER JOIN
  (
   SELECT p.object_id, p.index_id, MIN(p.[data_compression_desc]) AS [data_compression_desc]
   FROM sys.partitions p
   GROUP BY p.object_id, p.index_id
  ) p ON p.index_id = i.index_id AND p.object_id = t.object_id
  INNER JOIN /* Find the filegroup or partition scheme on which the index relies */
  (
   SELECT i.index_id, i.name, '[' + ps.name + ']([' + c.name + '])' AS IndexFilegroup
   FROM sys.tables t
   INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
   INNER JOIN sys.indexes i ON i.object_id = t.object_id
   INNER JOIN sys.partition_schemes ps ON ps.data_space_id = i.data_space_id
   INNER JOIN sys.partition_functions pf ON pf.function_id = ps.function_id
   INNER JOIN sys.index_columns ic ON ic.partition_ordinal > 0 AND ic.index_id = i.index_id AND ic.object_id = t.object_id
   INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
   WHERE 1 = 1
   AND s.name = @OriginalSchemaName
   AND t.name = @OriginalTableName
  UNION ALL
   SELECT i.index_id, i.name, '[' + d.name + ']' AS IndexFilegroup
   FROM sys.filegroups d
   INNER JOIN sys.indexes i ON i.data_space_id = d.data_space_id
   INNER JOIN sys.tables t ON t.object_id = i.object_id
   WHERE 1 = 1
   AND t.name = @OriginalTableName
   AND t.schema_id = schema_id(@OriginalSchemaName)
  ) ifg ON ifg.index_id = p.index_id
  WHERE EXISTS
  (
   SELECT *
   FROM sys.columns c
   WHERE c.column_id = ic.column_id
   AND c.object_id = ic.object_id
  )
  AND t.name = @OriginalTableName
  AND s.name = @OriginalSchemaName
 ), 
 IndexCTE2 AS
 (
  SELECT 
   IndexCTE.name AS IndexName,
   CASE IndexCTE.is_unique WHEN 1 THEN 'UNIQUE ' ELSE '' END AS IndexUnicity,
   CASE IndexCTE.index_id WHEN 1 THEN 'CLUSTERED' ELSE 'NONCLUSTERED' END AS IndexType,
   (
    SELECT CHAR(10) + '  ' + c.name + CASE ic.is_descending_key WHEN 0 THEN ' ASC' ELSE ' DESC' END + ','
    FROM sys.columns c
    INNER JOIN sys.index_columns ic
     ON c.object_id = ic.object_id
     AND ic.column_id = c.column_id
     AND ic.Is_Included_Column = 0
    WHERE IndexCTE.OBJECT_ID = ic.object_id
    AND IndexCTE.index_id = ic.index_id
    AND ic.key_ordinal > 0
    ORDER BY key_ordinal
    FOR XML PATH('')
   ) ixcols,
   ISNULL(
   (
    SELECT DISTINCT CHAR(10) + '  ' + c.name + ','
    FROM sys.columns c
    INNER JOIN sys.index_columns ic
     ON c.object_id = ic.object_id
     AND ic.column_id = c.column_id
     AND ic.Is_Included_Column = 1
    WHERE IndexCTE.OBJECT_ID = ic.object_id 
    AND IndexCTE.index_id = ic.index_id 
    FOR XML PATH('')
   ), '') includedcols,
   IndexCTE.index_id,
   IndexCTE.CompressionMode,
   IndexCTE.IndexFilegroup
  FROM indexCTE
 ),
 CreateSingleIndexQuery AS
 ( 
 SELECT 
  'CREATE ' + IndexUnicity + IndexType + ' INDEX [' + IndexName + '] ON [' + @TargetSchemaName + '].[' + @TargetTableName + ']' + CHAR(10) +
  '(' + SUBSTRING(ixcols, 1, LEN(ixcols) - 1) + CHAR(10) +
  CASE LEN(includedcols)
   WHEN 0 THEN ')'
   ELSE ')' + CHAR(10) + 'INCLUDE' + CHAR(10) + '(' + SUBSTRING(includedcols, 1, LEN(includedcols) - 1) + CHAR(10) + ')'
  END + CHAR(10) +
  'WITH ( DATA_COMPRESSION = ' + CompressionMode + ' )' + CHAR(10) +
  'ON ' + IndexFilegroup AS Query,
  index_id
 FROM IndexCTE2
 )
 SELECT @SqlQuery_CreateIndex = o.list
 FROM CreateSingleIndexQuery
 CROSS APPLY
 (
  SELECT CHAR(10) + Query + CHAR(10)
  FROM CreateSingleIndexQuery
  ORDER BY index_id
  FOR XML PATH('')
 ) o (list)


 SET @OutputQuery = @OutputQuery + @SqlQuery_CreateIndex
 END


END TRY

BEGIN CATCH
  -- Raise an error with the details of the exception
  DECLARE @ErrMsg nvarchar(4000), @ErrSeverity int
  SELECT @ErrMsg = ERROR_MESSAGE(),
         @ErrSeverity = ERROR_SEVERITY()

  RAISERROR(@ErrMsg, @ErrSeverity, 1)
END CATCH

END

Easy to use:
DECLARE @SqlQuery VARCHAR(4000) = ''
EXEC SPR_SCRIPT_CREATE_TABLE
  'dbo', 'existing_table',
  'dbo', 'switch_table',
  @SqlQuery OUTPUT

EXEC(@SqlQuery)

Friday, February 10, 2012

Rolling a SQL Server partition made easy

Rolling a SQL Server partition function is no big deal.
First time I had to implement it, I found it complicated because I was frightened by the SWITCH / SPLIT / MERGE / NEXT USED keywords I did not know, but as soon as I understood what was hidden behind, it became all clear.
Let's hope this post will clarify this amazing feature !

OK, just to make it clear from the beginning, I will not explain what is SQL Server partitioning... It has been widely covered on web and I have nothing to add !
Neither will I explain the best filegroup and partition strategies, which is a looong story and really depends on your specific context !

The Chuck Norris example

We need a working example. As usual when I feel lonely about finding inspiration, my friend Chuck Norris rescued me.
Suppose you are Chuck Norris and you want to store the name of all the people you saved life, and the date at which it happened. You will create an MSSQL table with 3 columns: date of intervention, first name, last name.
You save lot of lives (remember who you are), then the table will become huge in a few days. You have limited storage, the only choice you have is to keep only 5 days of data. Therefore, every night, you will run a job which will remove all the rows which are outdated.

They are many ways to implement this job:
  • Simple answer is to run a DELETE query. Hum... good idea if your table has only few records. If your table is big, DELETE operation may be very slow, fill your transactional log, and finally fail.
  • Another answer is to use a WHILE (do not mention cursor please) and DELETE the rows per block. Nice... depending of the indexes of your table, it may solve the "transactional log full" issue, but it will still be slow.
  • Another way is to create several tables, one per day, and use a UNION ALL view to "merge" all the dates. In this design, deleting outdated rows means DROP the oldest table. Do not laugh please, this is very serious. It is called partitioned views in SQL Server 7.0 and 2000, and it was, in my opinion, the best option when table partitioning did not exist.
  • Last option is to use a sliding window partitioning. If your design is perfect, partition rolling will be quick and will not overload your transactional log. Partitioning is good, but not in every case. I recommend you to read this post from Brent Ozar PLF which gives some alerts about partitioning usage (for instance you need the Enterprise version of MSSQL).

Rolling a sliding window partition

Overview

You can consider a sliding window partition as a wood box with a fixed number of slots you can use. But, in this wood box, there are two slots more you should never use : the -infinite slot and the +infinite slot (I will explain why later).
Sliding window partition : usable slots and forbidden slots
The picture above describes the Chuck Norris table. Suppose we are on 2012/01/05, meaning we want to keep people saved between 2012/01/01 and 2012/01/05 (included). Table is partitioned in 7 slots :
  1. The slot which contains saved people between ages and 2012/01/01. It is red because it shall always be empty.
  2. The slot which contains saved people between 2012/01/01 and 2012/01/02. It is blue because it may contain data.
  3. The slot for people saved on 2012/01/02.
  4. The slot for people saved on 2012/01/03.
  5. The slot for people saved on 2012/01/04.
  6. The slot for people saved on 2012/01/05.
  7. The slot which contains saved people after 2012/01/06. Again, it is red because it is future and shall always be empty.
When you want to operate a roll on this box, you have to free the oldest slot (not the -infinite one, but the very next one in blue), then merge the it with the -infinite one (it is quick because both are empty), and finally split the +infinite one in two empty slots.
I guess this is pretty clear, and I swear the next sections are nothing more complicated than this !

Step 1 : Create the Chuck scheme, table, partition function and scheme

Here we will create the big partitioned table in which Chuck will store the guys he saved life. But, since this table is partitioned, we have to create the corresponding partition function and partition scheme.
The following SQL is a bit long but does nothing complicated. It creates the objects I just mentioned, and fill the Chuck table with a few rows. Additionally, I recommend to create constraints on partitioned table to make sure no data is inserted out-of-bounds. This could lead to big slowness's in partition roll.

-- Create a partition function to store data for 5 days
CREATE PARTITION FUNCTION ChuckPartitionFunction(DATE) AS RANGE RIGHT FOR VALUES
(
    CAST('2012/01/01' AS DATE), CAST('2012/01/02' AS DATE),
    CAST('2012/01/03' AS DATE), CAST('2012/01/04' AS DATE),
    CAST('2012/01/05' AS DATE), CAST('2012/01/06' AS DATE)
)

GO

-- Create a partition scheme to map previous function on filegroups
CREATE PARTITION SCHEME ChuckPartitionScheme AS PARTITION ChuckPartitionFunction TO
(
    [PRIMARY], [PRIMARY], [PRIMARY],
    [PRIMARY], [PRIMARY], [PRIMARY], [PRIMARY]
)

GO

CREATE SCHEMA chuck

GO

-- Notice the final "ON [ChuckPartitionScheme](SaveDate)" : table is partitioned
CREATE TABLE chuck.[SavedPeople]
(
    SaveDate DATE,
    FirstName VARCHAR(256),
    LastName VARCHAR(256)
) ON ChuckPartitionScheme(SaveDate)

GO

-- Notice the final "ON [ChuckPartitionScheme](SaveDate)" :
-- clustered index is partitioned too
CREATE CLUSTERED INDEX PartitionedIndex ON chuck.SavedPeople
( SaveDate ASC )
ON ChuckPartitionScheme(SaveDate)

GO


-- Create a constraint to ensure no data in inserted out-of partitioned dates
ALTER TABLE chuck.SavedPeople
WITH NOCHECK ADD CONSTRAINT ChuckSavedPeople_PartitionDateCheck
    CHECK ( SaveDate >= CAST('2012/01/01' AS DATE)
    AND SaveDate < CAST('2012/01/06' AS DATE) )

GO

-- Insert a few rows in original table
INSERT INTO chuck.SavedPeople (SaveDate, FirstName, LastName)
VALUES
(CAST('2012/01/01' AS DATE), 'Attila', 'Le Hun'),
(CAST('2012/01/01' AS DATE), 'Napoleon', 'Bonaparte'),
(CAST('2012/01/02' AS DATE), 'Alexandre', 'Dumas'),
(CAST('2012/01/02' AS DATE), 'Emile', 'Zola'),
(CAST('2012/01/03' AS DATE), 'Edgar Allan', 'Poe'),
(CAST('2012/01/03' AS DATE), 'Francoise', 'Chandernagor'),
(CAST('2012/01/04' AS DATE), 'Honore', 'De Balzac'),
(CAST('2012/01/04' AS DATE), 'Robert', 'Merle'),
(CAST('2012/01/05' AS DATE), 'Marcel', 'Proust'),
(CAST('2012/01/05' AS DATE), 'Jean', 'Giono')

GO

-- Insert more and more rows in original table. This may be time consuming.
DECLARE @COUNTER INT = 0
WHILE @COUNTER < 18
BEGIN
 INSERT INTO chuck.SavedPeople
 SELECT * FROM chuck.SavedPeople
 SET @COUNTER = @COUNTER + 1
END

-- Ensure the original data has enough rows
SELECT COUNT(*) FROM chuck.SavedPeople

Step 2 : Create a temporary box in which you will free the out-of-date slot

When you want to roll a partition, you have to switch the slot you want to free into a new table. This later table must have the exact same structure (including clustered index) as the table you want to roll, else SQL engine will not be happy. Furthermore, this new table may be partitioned the same way the original table is, or may not be... the only restriction is that it must reside in the same filegroup (and this table must be empty). In my example it is easy because I am using PRIMARY (bad bad bad !) Hence you cannot use a temp table because it would be created in tempdb database, which is not the same filegroup !

-- Create a table with the exact same structure as the original one
CREATE TABLE chuck.SavedPeople_TobeDropped
(
    SaveDate DATE,
    FirstName VARCHAR(256),
    LastName VARCHAR(256)
)

GO

-- Clustered index has to be created too !!!
CREATE CLUSTERED INDEX PartitionedIndex ON chuck.SavedPeople_TobeDropped
( SaveDate ASC )

Step 3 : Free the out-of-date slot

Once new table is created, you have to use SWITCH keyword to move data from slot of original table (purple one) to new table (green one). After this operation, your original table does not contain anymore the data you wanted to free, they have been instantly moved to the new table. You just need to DROP this new table to get rid of the data. No big DELETE need.
To switch a partition with lot of data into an empty table is efficient
-- Move data from slot 2 of original table to the new table
ALTER TABLE chuck.SavedPeople SWITCH PARTITION 2 TO chuck.SavedPeople_TobeDropped

GO

-- Ensure that data have been moved
select COUNT(*) from chuck.SavedPeople -- All data except 2012/01/01
select COUNT(*) from chuck.SavedPeople_TobeDropped -- Only 2012/01/01

-- Destroy the temporary table in which we did operate the switch
DROP TABLE chuck.SavedPeople_TobeDropped

GO

select COUNT(*) from chuck.SavedPeople -- All data except 2012/01/01

Step 4 : Merge and split partition function

Until now, we just destroyed the data we did not want to store anymore. We did not slide (or roll) the window partition. This is done using the keywords MERGE and SPLIT (with NEXT USED).
Split and merge of partitions must be performed on EMPTY slots
MERGE deletes a bound from partition function, which is equivalent to regroup 2 slots into 1 single slot. This operation is efficient only if the slots you try to merge are empty, else it may consume time and space (transactional log). That is why I said that the first slot must always be empty (remember the constraint we added on Chuck table).

SPLIT creates 2 slots from 1 single slot (which must be empty). But, you will ask : where is the new partition created ? Good question ! Before execution SPLIT statement, you shall execute NEXT USED statement to tell SQL engine which filegroup to use on the new slot. Note that NEXT USED statement has to be called for every partition scheme using the partition function, since the filegroups are attached to scheme, not to functions. In the Chuck example, this is not important because there is only one partition scheme.

-- Merge the 2 first slots which should be empty
ALTER PARTITION FUNCTION ChuckPartitionFunction()
    MERGE RANGE (CAST('2012/01/01' AS DATE))

-- Prepare the SPLIT operation by telling the SQL
-- engine we will create a new slot in PRIMARY filegroup
ALTER PARTITION SCHEME ChuckPartitionScheme NEXT USED [PRIMARY];

-- Split the last slot which should be empty :
-- create a new bound in partition function
ALTER PARTITION FUNCTION ChuckPartitionFunction()
    SPLIT RANGE (CAST('2012/01/07' AS DATE))

Step 5 : Update the constraints

It is almost over ! One last thing before leaving : we shall update the constraint we defined on Chuck table. Currently, it is checking that any data in the table has a SaveDate between 1st of January and 5th of January. Now, since we rolled the partition, it should be between 2nd of January and 6th of January.

-- Update the previous constraint on dates
ALTER TABLE chuck.SavedPeople
    DROP CONSTRAINT ChuckSavedPeople_PartitionDateCheck

ALTER TABLE chuck.SavedPeople WITH NOCHECK
    ADD CONSTRAINT ChuckSavedPeople_PartitionDateCheck
    CHECK  ( SaveDate >= CAST('2012/01/02' AS DATE)
    AND SaveDate < CAST('2012/01/07' AS DATE) )

Before running in production...

Wait a minute before releasing this in production :)
First of all, I put GO everywhere, which is not a good idea except for demo purpose.
Then, I highly recommend to put the steps 2 to 5 in a transaction, because if something goes wrong during these steps I do not guarantee stability of your partition functions and table. The step 1 is not part the partition roll (it may be an ETL process which feeds the big table), that is why I do not include it in transaction.
What is more, even though the partition roll is simple enough, it may keep some surprises, so you should stress test your code before running it in a sensitive environment. I encountered following issues which took some time to solve:
  • locking issue : perform the roll while tables are being fed is a bad idea.
  • bulk insert may disable constraints on table while insertion, meaning that data may be inserted in an out-of date slot ! This is dramatic because SPLIT and MERGE need empty slots in order to be efficient !
  • if you have several partition scheme based on the same partition function, the above code becomes a bit more complex.
Finally, you noticed I hard-coded dates in the SQL script. Once again, this is only for demo purpose. In real production context you will have to dynamically compute the new dates (or bounds, more generally).
It is possible to write a facility (in C# for instance) using SMO and system tables to generate the roll script with the proper bounds every day (or week or whatever). But, this is very context-dependent and is hard to generalize... so my opinion is to understand the partition rolling and have fun writing your own one !

References

Partitioning within MSSQL 2005
http://msdn.microsoft.com/en-us/library/ms345146%28v=sql.90%29.aspx

CREATE PARTITION FUNCTION by MSDN
http://msdn.microsoft.com/en-us/library/ms187802%28v=sql.100%29.aspx

ALTER TABLE by MSDN
http://msdn.microsoft.com/en-us/library/ms190273%28v=sql.100%29.aspx

ALTER PARTITION by MSDN
http://msdn.microsoft.com/en-us/library/ms186307(v=sql.100).aspx

SQL Server Partitioning: Not the Best Practices for Everything
http://www.brentozar.com/archive/2008/06/sql-server-partitioning-not-the-answer-to-everything/

Sunday, January 22, 2012

Get size of every partition of a SSAS cube

Today, I had to check the sizes of every partitions of an OLAP cube, to ensure size of partitions is homogeneous. I did not find how to do that using a simple xmla query.So I decided to write a small console application in C# (VS2008) to perform it. The quickest way is to use the Management Object facility provided by Microsoft: it is a API which allows to connect to the online cube and browse its structure, read data, alter cube structure, launch process... anything you want !


Create project and reference SSAS DLL

First of all, create a project in Visual Studio (console application for instance).
Then, you have to add a reference in the project to the SSAS management object dll. Could find why Microsoft did it this way, but this dll has a strange component name. Where you expect something like Microsoft.AnalysisServices.xxx, you have to find Analysis Management Objects. So be careful when you link this assembly in Visual Studio.
Link the Analysis Services Management Object assembly to your project

Remember I am a bit lazy ? Yes, I cannot bear to write a long namespace several times... No problem we will use an alias.
using AS = Microsoft.AnalysisServices;



Browse the cube and get partitions size

Now, declare a small structure which will contains the information we need about a partition.
public struct PartitionInfo
{
    public string CubeName;
    public string MeasureGroup;
    public string PartitionName;
    public long EstimatedSize;
}

Here comes the interesting point. We will write the method which lists all the partitions and their size for a given SSAS database. The parameters are a ConnectionString (which does not need Initial Catalog, since it is next parameter), and the name of database you want to connect to.
The method performs the following actions:
  1. Connect to AS server using the command line provided as first parameter (line 4 and 6)
  2. Open the database which matches the name provided as second parameter (line 7)
  3. Browse every cube available in the database (line 8)
  4. Browse every measure group available in the current cube (line 10)
  5. Browse every partition available in the current measure group (line 12)
  6. For each of these partitions, save its definition (name, parent measure group, parent cube) and its size (lines 14 to 19). Even though property name is EstimatedSize, in my experience its value was always quite correct.
  7. Return the list of partitions we found to the caller (line 24)
public static IList<partitioninfo> GetListOfPartitions(string ConnectionString, string DatabaseName)
{
    List<partitioninfo> LPI = new List<partitioninfo>();
    using (AS.Server Server = new AS.Server())
    {
        Server.Connect(ConnectionString);
        AS.Database Database = Server.Databases.FindByName(DatabaseName);
        foreach (AS.Cube Cube in Database.Cubes)
        {
            foreach (AS.MeasureGroup MG in Cube.MeasureGroups)
            {
                foreach (AS.Partition P in MG.Partitions)
                {
                    PartitionInfo PI = new PartitionInfo();
                    PI.CubeName = Cube.Name;
                    PI.MeasureGroup = MG.Name;
                    PI.PartitionName = P.Name;
                    PI.EstimatedSize = P.EstimatedSize;
                    LPI.Add(PI);
                }
            }
        }
    }
    return LPI;
}

Pay special attention if you are debugging this code with JIT debugger, I noticed it does not work fine : the Database object does not support to be watched by debugger, this could lead your SSAS objects to be unusable during process execution. For instance I could not find any cube in my database in debug mode, but it works fine in normal execution mode.


Save your data to CSV file

To finish the job, it may be useful to save the loaded data into a CSV file. In my case. I have to check that all partitions have homogeneous sizes, and regroup some small partitions together, so it is of great help to load the results in Excel !
public static void SaveToCsv(IList<partitioninfo> LPI, string Filename)
{
    using (System.IO.StreamWriter SW = new System.IO.StreamWriter(Filename))
    {
        SW.WriteLine("CubeName,MeasureGroup,PartitionName,EstimatedSizeInBytes");
        foreach (PartitionInfo PI in LPI)
        {
            string Row = string.Format("{0},{1},{2},{3}", PI.CubeName, PI.MeasureGroup, PI.PartitionName, PI.EstimatedSize);
            SW.WriteLine(Row);
        }
    }
}